Merge branch 'main' into worktree-ssrf-redirect-fix

This commit is contained in:
Rip&Tear
2026-06-14 14:49:21 +08:00
committed by GitHub
108 changed files with 12182 additions and 4520 deletions

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.7a2",
"crewai-core==1.14.7",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",

View File

@@ -1 +1 @@
__version__ = "1.14.7a2"
__version__ = "1.14.7"

View File

@@ -26,6 +26,7 @@ from crewai_cli.remote_template.main import TemplateCommand
from crewai_cli.replay_from_task import replay_task_command
from crewai_cli.reset_memories_command import reset_memories_command
from crewai_cli.run_crew import run_crew
from crewai_cli.run_flow_definition import run_flow_definition
from crewai_cli.settings.main import SettingsCommand
from crewai_cli.task_outputs import load_task_outputs
from crewai_cli.tools.main import ToolCommand
@@ -398,8 +399,36 @@ def install(context: click.Context) -> None:
"CREWAI_TRAINED_AGENTS_FILE."
),
)
def run(trained_agents_file: str | None) -> None:
"""Run the Crew."""
@click.option(
"--definition",
type=str,
default=None,
help=(
"Experimental: path to a Flow Definition YAML/JSON file, "
"or an inline YAML/JSON string."
),
)
@click.option(
"--inputs",
type=str,
default=None,
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
)
def run(
trained_agents_file: str | None, definition: str | None, inputs: str | None
) -> None:
"""Run the Crew or Flow."""
if inputs is not None and definition is None:
raise click.UsageError("--inputs requires --definition")
if definition is not None:
click.secho(
"Warning: `crewai run --definition` is experimental and may change without notice.",
fg="yellow",
)
run_flow_definition(definition=definition, inputs=inputs)
return
run_crew(trained_agents_file=trained_agents_file)

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import click
def run_flow_definition(definition: str, inputs: str | None = None) -> None:
"""Run a flow from a Flow Definition YAML/JSON string or file path."""
try:
from crewai.flow.flow import Flow
from crewai.flow.flow_definition import FlowDefinition
except ImportError as exc:
click.echo(
"Running flows from definitions requires the full crewai package.",
err=True,
)
raise SystemExit(1) from exc
parsed_inputs = _parse_inputs(inputs)
definition_source = _read_definition_source(definition)
try:
flow_definition = _parse_flow_definition(FlowDefinition, definition_source)
flow = Flow.from_definition(flow_definition)
result = flow.kickoff(inputs=parsed_inputs)
except Exception as exc:
click.echo(
f"An error occurred while running the flow definition: {exc}", err=True
)
raise SystemExit(1) from exc
click.echo(_format_result(result))
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
if inputs is None:
return None
try:
parsed = json.loads(inputs)
except json.JSONDecodeError as exc:
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
raise SystemExit(1) from exc
if not isinstance(parsed, dict):
click.echo("Invalid --inputs JSON: expected an object.", err=True)
raise SystemExit(1)
return parsed
def _read_definition_source(definition: str) -> str:
path = Path(definition).expanduser()
try:
is_file = path.is_file()
except OSError as exc:
if _looks_like_inline_definition(definition):
return definition
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
if is_file:
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
click.echo(
f"Unable to read --definition path {path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
try:
if path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
return definition
def _looks_like_inline_definition(definition: str) -> bool:
stripped = definition.lstrip()
return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped
def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any:
if _looks_like_json(source):
return flow_definition_cls.from_json(source)
return flow_definition_cls.from_yaml(source)
def _looks_like_json(source: str) -> bool:
stripped = source.lstrip()
return stripped.startswith("{")
def _format_result(result: Any) -> str:
raw_result = getattr(result, "raw", result)
if isinstance(raw_result, str):
return raw_result
try:
return json.dumps(raw_result, default=str)
except TypeError:
return str(raw_result)

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7a2"
"crewai[tools]==1.14.7"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7a2"
"crewai[tools]==1.14.7"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.7a2"
"crewai[tools]==1.14.7"
]
[tool.crewai]

View File

@@ -13,6 +13,7 @@ from crewai_cli.cli import (
flow_add_crew,
login,
reset_memories,
run,
test,
train,
version,
@@ -119,6 +120,43 @@ def test_test_invalid_string_iterations(evaluate_crew, runner):
)
@mock.patch("crewai_cli.cli.run_crew")
def test_run_uses_project_runner_by_default(run_crew, runner):
result = runner.invoke(run)
assert result.exit_code == 0
run_crew.assert_called_once_with(trained_agents_file=None)
assert "experimental" not in result.output.lower()
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_with_definition_uses_definition_runner(run_flow_definition, runner):
result = runner.invoke(
run,
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
)
assert result.exit_code == 0
assert (
"Warning: `crewai run --definition` is experimental and may change without notice."
in result.output
)
run_flow_definition.assert_called_once_with(
definition="flow.yaml", inputs='{"topic":"AI"}'
)
@mock.patch("crewai_cli.cli.run_crew")
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner):
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
assert result.exit_code == 2
assert "Error: --inputs requires --definition" in result.output
run_flow_definition.assert_not_called()
run_crew.assert_not_called()
@mock.patch("crewai_cli.cli.AuthenticationCommand")
def test_login(command, runner):
mock_auth = command.return_value

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import json
import sys
import types
import pytest
import yaml
from crewai_cli.run_flow_definition import run_flow_definition
class _FakeFlow:
def __init__(self, definition):
self.definition = definition
def kickoff(self, inputs=None):
return {
"flow": self.definition["name"],
"inputs": inputs or {},
}
class _FakeFlowFactory:
@classmethod
def from_definition(cls, definition):
return _FakeFlow(definition)
class _FakeFlowDefinition:
@classmethod
def from_yaml(cls, source):
return yaml.safe_load(source)
@classmethod
def from_json(cls, source):
return json.loads(source)
@pytest.fixture
def fake_flow_runtime(monkeypatch):
crewai_module = types.ModuleType("crewai")
flow_package = types.ModuleType("crewai.flow")
flow_module = types.ModuleType("crewai.flow.flow")
flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
flow_module.Flow = _FakeFlowFactory
flow_definition_module.FlowDefinition = _FakeFlowDefinition
monkeypatch.setitem(sys.modules, "crewai", crewai_module)
monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
monkeypatch.setitem(
sys.modules, "crewai.flow.flow_definition", flow_definition_module
)
def _captured_json(capsys):
return json.loads(capsys.readouterr().out)
def test_run_flow_definition_reads_definition_file(
tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
run_flow_definition(str(definition_path), '{"topic":"AI"}')
assert _captured_json(capsys) == {
"flow": "TestFlow",
"inputs": {"topic": "AI"},
}
@pytest.mark.parametrize(
("definition_source", "expected_flow_name"),
[
pytest.param(
"schema: crewai.flow/v1\nname: InlineFlow\n",
"InlineFlow",
id="inline-yaml",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}',
"InlineJsonFlow",
id="inline-json",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}',
"JsonFlow" * 500,
id="large-inline-json",
),
],
)
def test_run_flow_definition_accepts_inline_definitions(
definition_source, expected_flow_name, capsys, fake_flow_runtime
):
run_flow_definition(definition_source)
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
@pytest.mark.parametrize(
("filename", "definition_source", "expected_flow_name"),
[
pytest.param(
"flow.yaml",
"schema: crewai.flow/v1\nname: YamlFileFlow\n",
"YamlFileFlow",
id="yaml-file",
),
pytest.param(
"flow.json",
'{"schema":"crewai.flow/v1","name":"JsonFlow"}',
"JsonFlow",
id="json-file",
),
],
)
def test_run_flow_definition_accepts_definition_files(
filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / filename
definition_path.write_text(definition_source)
run_flow_definition(str(definition_path))
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys):
with pytest.raises(SystemExit):
run_flow_definition("name: TestFlow", '["not", "an", "object"]')
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
def test_run_flow_definition_reports_unreadable_file(
monkeypatch, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
def raise_permission_error(self, *args, **kwargs):
raise PermissionError("no access")
monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
with pytest.raises(SystemExit):
run_flow_definition(str(definition_path))
err = capsys.readouterr().err
assert "Unable to read --definition path" in err
assert str(definition_path) in err
assert "no access" in err

View File

@@ -1 +1 @@
__version__ = "1.14.7a2"
__version__ = "1.14.7"

View File

@@ -17,7 +17,7 @@ import contextlib
import logging
import os
import threading
from typing import Any, Final
from typing import Any, ClassVar, Final
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
@@ -27,7 +27,7 @@ from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
SpanExportResult,
)
from opentelemetry.trace import Span, Status, StatusCode
from opentelemetry.trace import ProxyTracerProvider, Span, Status, StatusCode
from typing_extensions import Self
@@ -72,8 +72,8 @@ class Telemetry:
and event-bus signal handlers (see ``crewai.telemetry.telemetry``).
"""
_instance = None
_lock = threading.Lock()
_instance: ClassVar[Self | None] = None
_lock: ClassVar[threading.Lock] = threading.Lock()
def __new__(cls) -> Self:
if cls._instance is None:
@@ -149,6 +149,10 @@ class Telemetry:
if self.ready and not self.trace_set:
try:
with suppress_warnings():
existing_provider = trace.get_tracer_provider()
if not isinstance(existing_provider, ProxyTracerProvider):
self.trace_set = True
return
trace.set_tracer_provider(self.provider)
self.trace_set = True
except Exception as e:

View File

@@ -13,6 +13,7 @@ from crewai_core import (
user_data,
version,
)
from opentelemetry.sdk.trace import TracerProvider
import pytest
@@ -94,3 +95,36 @@ def test_user_data_decline_blocks(
def test_unused_var_warning_silenced() -> None:
# Touch os to keep the import (used by env-var fixtures above)
assert os.environ is not None
def test_core_telemetry_skips_duplicate_tracer_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from crewai_core.telemetry import Telemetry
Telemetry._instance = None
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
monkeypatch.setattr(
"crewai_core.telemetry.trace.get_tracer_provider",
lambda: TracerProvider(),
)
called = False
def fail_if_called(provider: object) -> None:
nonlocal called
called = True
monkeypatch.setattr(
"crewai_core.telemetry.trace.set_tracer_provider",
fail_if_called,
)
telemetry = Telemetry()
telemetry.set_tracer()
assert called is False
assert telemetry.trace_set is True

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.7a2"
__version__ = "1.14.7"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.7a2",
"crewai==1.14.7",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -63,7 +63,7 @@ spider-client = [
"spider-client>=0.1.25",
]
scrapegraph-py = [
"scrapegraph-py>=1.9.0",
"scrapegraph-py>=1.9.0,<2",
]
linkup-sdk = [
"linkup-sdk>=0.2.2",

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.7a2"
__version__ = "1.14.7"

View File

@@ -22,6 +22,31 @@ logger = logging.getLogger(__name__)
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
def format_path_for_display(path: str, base_dir: str | None = None) -> str:
"""Return a path label that does not expose absolute directory prefixes."""
if base_dir is None:
base_dir = os.getcwd()
try:
resolved_base = os.path.realpath(base_dir)
resolved_path = os.path.realpath(
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
)
if os.path.commonpath([resolved_base, resolved_path]) == resolved_base:
return os.path.relpath(resolved_path, resolved_base)
except (OSError, ValueError) as exc:
logger.debug("Falling back to basename for display path formatting: %s", exc)
return os.path.basename(os.path.realpath(path)) or "[redacted path]"
def format_error_for_display(error: Exception) -> str:
"""Return exception details without OS-added absolute path context."""
if isinstance(error, OSError):
return error.strerror or error.__class__.__name__
return str(error)
def _is_escape_hatch_enabled() -> bool:
"""Check if the unsafe paths escape hatch is enabled."""
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
@@ -66,8 +91,8 @@ def validate_file_path(path: str, base_dir: str | None = None) -> str:
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
raise ValueError(
f"Path '{path}' resolves to '{resolved_path}' which is outside "
f"the allowed directory '{resolved_base}'. "
f"Path '{format_path_for_display(resolved_path, resolved_base)}' is "
f"outside the allowed directory. "
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
)

View File

@@ -3,7 +3,11 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_file_path
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
validate_file_path,
)
class FileReadToolSchema(BaseModel):
@@ -58,8 +62,9 @@ class FileReadTool(BaseTool):
**kwargs: Additional keyword arguments passed to BaseTool.
"""
if file_path is not None:
display_path = format_path_for_display(file_path)
kwargs["description"] = (
f"A tool that reads file content. The default file is {file_path}, but you can provide a different 'file_path' parameter to read another file. You can also specify 'start_line' and 'line_count' to read specific parts of the file."
f"A tool that reads file content. The default file is {display_path}, but you can provide a different 'file_path' parameter to read another file. You can also specify 'start_line' and 'line_count' to read specific parts of the file."
)
super().__init__(**kwargs)
@@ -78,7 +83,12 @@ class FileReadTool(BaseTool):
if file_path is None:
return "Error: No file path provided. Please provide a file path either in the constructor or as an argument."
file_path = validate_file_path(file_path)
try:
file_path = validate_file_path(file_path)
except ValueError as e:
return f"Error: Invalid file path: {e!s}"
display_path = format_path_for_display(file_path)
try:
with open(file_path, "r") as file:
if start_line == 1 and line_count is None:
@@ -98,8 +108,11 @@ class FileReadTool(BaseTool):
return "".join(selected_lines)
except FileNotFoundError:
return f"Error: File not found at path: {file_path}"
return f"Error: File not found at path: {display_path}"
except PermissionError:
return f"Error: Permission denied when trying to read file: {file_path}"
return f"Error: Permission denied when trying to read file: {display_path}"
except Exception as e:
return f"Error: Failed to read file {file_path}. {e!s}"
return (
f"Error: Failed to read file {display_path}. "
f"{format_error_for_display(e)}"
)

View File

@@ -5,6 +5,11 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
)
def strtobool(val: str | bool) -> bool:
if isinstance(val, bool):
@@ -44,6 +49,9 @@ class FileWriterTool(BaseTool):
# itself, since that is not a valid file target.
real_directory = Path(directory).resolve()
real_filepath = Path(filepath).resolve()
display_filepath = format_path_for_display(
str(real_filepath), str(real_directory)
)
if (
not real_filepath.is_relative_to(real_directory)
or real_filepath == real_directory
@@ -56,15 +64,18 @@ class FileWriterTool(BaseTool):
kwargs["overwrite"] = strtobool(kwargs["overwrite"])
if os.path.exists(real_filepath) and not kwargs["overwrite"]:
return f"File {real_filepath} already exists and overwrite option was not passed."
return f"File {display_filepath} already exists and overwrite option was not passed."
mode = "w" if kwargs["overwrite"] else "x"
with open(real_filepath, mode) as file:
file.write(kwargs["content"])
return f"Content successfully written to {real_filepath}"
return f"Content successfully written to {display_filepath}"
except FileExistsError:
return f"File {real_filepath} already exists and overwrite option was not passed."
return f"File {display_filepath} already exists and overwrite option was not passed."
except KeyError as e:
return f"An error occurred while accessing key: {e!s}"
except Exception as e:
return f"An error occurred while writing to the file: {e!s}"
return (
"An error occurred while writing to the file: "
f"{format_error_for_display(e)}"
)

View File

@@ -1,4 +1,3 @@
import os
from unittest.mock import mock_open, patch
from crewai_tools import FileReadTool
@@ -6,21 +5,16 @@ from crewai_tools import FileReadTool
def test_file_read_tool_constructor():
"""Test FileReadTool initialization with file_path."""
test_file = "/tmp/test_file.txt"
test_content = "Hello, World!"
with open(test_file, "w") as f:
f.write(test_content)
test_file = "test_file.txt"
tool = FileReadTool(file_path=test_file)
assert tool.file_path == test_file
assert "test_file.txt" in tool.description
os.remove(test_file)
def test_file_read_tool_run():
"""Test FileReadTool _run method with file_path at runtime."""
test_file = "/tmp/test_file.txt"
test_file = "test_file.txt"
test_content = "Hello, World!"
# Use mock_open to mock file operations
@@ -36,18 +30,18 @@ def test_file_read_tool_error_handling():
result = tool._run()
assert "Error: No file path provided" in result
result = tool._run(file_path="/nonexistent/file.txt")
result = tool._run(file_path="nonexistent/file.txt")
assert "Error: File not found at path:" in result
with patch("builtins.open", side_effect=PermissionError()):
result = tool._run(file_path="/tmp/no_permission.txt")
result = tool._run(file_path="no_permission.txt")
assert "Error: Permission denied" in result
def test_file_read_tool_constructor_and_run():
"""Test FileReadTool using both constructor and runtime file paths."""
test_file1 = "/tmp/test1.txt"
test_file2 = "/tmp/test2.txt"
test_file1 = "test1.txt"
test_file2 = "test2.txt"
content1 = "File 1 content"
content2 = "File 2 content"
@@ -64,7 +58,7 @@ def test_file_read_tool_constructor_and_run():
def test_file_read_tool_chunk_reading():
"""Test FileReadTool reading specific chunks of a file."""
test_file = "/tmp/multiline_test.txt"
test_file = "multiline_test.txt"
lines = [
"Line 1\n",
"Line 2\n",
@@ -104,7 +98,7 @@ def test_file_read_tool_chunk_reading():
def test_file_read_tool_chunk_error_handling():
"""Test error handling for chunk reading."""
test_file = "/tmp/short_test.txt"
test_file = "short_test.txt"
lines = ["Line 1\n", "Line 2\n", "Line 3\n"]
file_content = "".join(lines)
@@ -122,7 +116,7 @@ def test_file_read_tool_chunk_error_handling():
def test_file_read_tool_zero_or_negative_start_line():
"""Test that start_line values of 0 or negative read from the start of the file."""
test_file = "/tmp/negative_test.txt"
test_file = "negative_test.txt"
lines = ["Line 1\n", "Line 2\n", "Line 3\n", "Line 4\n", "Line 5\n"]
file_content = "".join(lines)
@@ -150,3 +144,45 @@ def test_file_read_tool_zero_or_negative_start_line():
result = tool._run(file_path=test_file, start_line=-10, line_count=2)
expected = "".join(lines[0:2]) # Should read first 2 lines
assert result == expected
def test_file_read_tool_error_messages_do_not_disclose_absolute_paths(
tmp_path, monkeypatch
):
"""FileReadTool should redact absolute prefixes from user-visible errors."""
monkeypatch.chdir(tmp_path)
tool = FileReadTool()
target = tmp_path / "secret.txt"
result = tool._run(file_path=str(target))
assert "secret.txt" in result
assert str(tmp_path) not in result
target.touch()
with patch("builtins.open", side_effect=PermissionError()):
result = tool._run(file_path=str(target))
assert "secret.txt" in result
assert str(tmp_path) not in result
with patch(
"builtins.open",
side_effect=OSError(5, "Input/output error", str(target)),
):
result = tool._run(file_path=str(target))
assert "secret.txt" in result
assert str(tmp_path) not in result
def test_file_read_tool_invalid_path_error_does_not_disclose_workspace(
tmp_path, monkeypatch
):
"""Validation errors should not echo the resolved workspace path."""
monkeypatch.chdir(tmp_path)
outside = tmp_path.parent / "outside.txt"
result = FileReadTool()._run(file_path=str(outside))
assert "Invalid file path" in result
assert "outside.txt" in result
assert str(tmp_path) not in result
assert str(tmp_path.parent) not in result

View File

@@ -47,6 +47,8 @@ def test_basic_file_write(tool, temp_env):
assert os.path.exists(path)
assert read_file(path) == temp_env["test_content"]
assert "successfully written" in result
assert temp_env["test_file"] in result
assert temp_env["temp_dir"] not in result
def test_directory_creation(tool, temp_env):
@@ -62,6 +64,8 @@ def test_directory_creation(tool, temp_env):
assert os.path.exists(new_dir)
assert os.path.exists(path)
assert "successfully written" in result
assert temp_env["test_file"] in result
assert new_dir not in result
@pytest.mark.parametrize(
@@ -134,6 +138,8 @@ def test_file_exists_error_handling(tool, temp_env, overwrite):
)
assert "already exists and overwrite option was not passed" in result
assert temp_env["test_file"] in result
assert temp_env["temp_dir"] not in result
assert read_file(path) == "Pre-existing content"

View File

@@ -7,6 +7,7 @@ import os
import pytest
from crewai_tools.security.safe_path import (
format_path_for_display,
validate_directory_path,
validate_file_path,
validate_url,
@@ -66,6 +67,37 @@ class TestValidateFilePath:
result = validate_file_path("/etc/passwd", str(tmp_path))
assert result == os.path.realpath("/etc/passwd")
def test_rejection_message_redacts_absolute_prefixes(self, tmp_path):
outside = tmp_path.parent / "outside.txt"
with pytest.raises(ValueError) as exc_info:
validate_file_path(str(outside), str(tmp_path))
message = str(exc_info.value)
assert "outside.txt" in message
assert str(tmp_path) not in message
assert str(tmp_path.parent) not in message
class TestFormatPathForDisplay:
"""Tests for user-visible path labels."""
def test_returns_relative_path_inside_base(self, tmp_path):
nested_file = tmp_path / "nested" / "file.txt"
nested_file.parent.mkdir()
nested_file.touch()
result = format_path_for_display(str(nested_file), str(tmp_path))
assert result == os.path.join("nested", "file.txt")
def test_redacts_absolute_prefix_outside_base(self, tmp_path):
outside_file = tmp_path.parent / "outside.txt"
result = format_path_for_display(str(outside_file), str(tmp_path))
assert result == "outside.txt"
class TestValidateDirectoryPath:
"""Tests for validate_directory_path."""

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.7a2",
"crewai-cli==1.14.7a2",
"crewai-core==1.14.7",
"crewai-cli==1.14.7",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -33,11 +33,12 @@ dependencies = [
"appdirs~=1.4.4",
"jsonref~=1.1.0",
"json-repair~=0.25.2",
"cel-python>=0.5.0,<0.6",
"tomli-w~=1.1.0",
"tomli~=2.0.2",
"json5~=0.10.0",
"portalocker~=2.7.0",
"pydantic-settings~=2.10.1",
"pydantic-settings>=2.10.1,<3",
"httpx~=0.28.1",
"mcp~=1.26.0",
"aiosqlite~=0.21.0",
@@ -54,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.7a2",
"crewai-tools==1.14.7",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -67,7 +68,11 @@ openpyxl = [
]
mem0 = ["mem0ai>=2.0.0,<3"]
docling = [
"docling~=2.84.0",
"docling~=2.97.0",
# docling 2.97 split into docling-slim; the chunker package (HierarchicalChunker)
# now eagerly imports code-chunking submodules that need tree-sitter/semchunk,
# which only the docling-core[chunking] extra provides.
"docling-core[chunking]>=2.74.1",
]
qdrant = [
"qdrant-client[fastembed]~=1.14.3",

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.7a2"
__version__ = "1.14.7"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -46,6 +46,7 @@ from crewai.state.checkpoint_config import CheckpointConfig, _coerce_checkpoint
from crewai.tools.base_tool import BaseTool, Tool
from crewai.types.callback import SerializableCallable
from crewai.utilities.config import process_config
from crewai.utilities.i18n import I18N, get_i18n
from crewai.utilities.logger import Logger
from crewai.utilities.rpm_controller import RPMController
from crewai.utilities.string_utils import interpolate_only
@@ -81,6 +82,7 @@ _LLM_TYPE_REGISTRY: dict[str, str] = {
def _validate_llm_ref(value: Any) -> Any:
if isinstance(value, dict):
import importlib
import inspect
llm_type = value.get("llm_type")
if not llm_type or llm_type not in _LLM_TYPE_REGISTRY:
@@ -91,6 +93,12 @@ def _validate_llm_ref(value: Any) -> Any:
dotted = _LLM_TYPE_REGISTRY[llm_type]
mod_path, cls_name = dotted.rsplit(".", 1)
cls = getattr(importlib.import_module(mod_path), cls_name)
if inspect.isabstract(cls):
from crewai.llm import LLM
return LLM(
**{k: v for k, v in value.items() if v is not None and k != "llm_type"}
)
return cls(**value)
return value
@@ -186,6 +194,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
tools (list[Any] | None): Tools at the agent's disposal.
max_iter (int): Maximum iterations for an agent to execute a task.
agent_executor: An instance of the CrewAgentExecutor class.
i18n (I18N): Internationalization settings.
llm (Any): Language model that will run the agent.
crew (Any): Crew to which the agent belongs.
@@ -265,6 +274,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
_serialize_executor_ref, return_type=dict | None, when_used="json"
),
] = Field(default=None, description="An instance of the CrewAgentExecutor class.")
i18n: I18N = Field(
default_factory=get_i18n,
description="Internationalization settings.",
deprecated=(
"Agent.i18n is deprecated and will be removed in a future release. "
"Use crewai.utilities.i18n.get_i18n() or Crew(prompt_file=...) instead."
),
)
llm: Annotated[
str | BaseLLM | None,

View File

@@ -117,8 +117,10 @@ def capture_execution_context(
)
def apply_execution_context(ctx: ExecutionContext) -> None:
def apply_execution_context(ctx: ExecutionContext | dict[str, Any]) -> None:
"""Write an ExecutionContext back into the ContextVars."""
if isinstance(ctx, dict):
ctx = ExecutionContext.model_validate(ctx)
_current_task_id.set(ctx.current_task_id)
current_flow_request_id.set(ctx.flow_request_id)
current_flow_id.set(ctx.flow_id)

View File

@@ -1013,6 +1013,7 @@ class Crew(FlowTrackable, BaseModel):
)
token = attach(baggage_ctx)
runtime_scope = crewai_event_bus._enter_runtime_scope()
try:
inputs = prepare_kickoff(self, inputs, input_files)
@@ -1048,6 +1049,7 @@ class Crew(FlowTrackable, BaseModel):
self._memory.drain_writes()
clear_files(self.id)
detach(token)
crewai_event_bus._exit_runtime_scope(runtime_scope)
def _post_kickoff(self, result: CrewOutput) -> CrewOutput:
return result
@@ -1223,6 +1225,7 @@ class Crew(FlowTrackable, BaseModel):
)
token = attach(baggage_ctx)
runtime_scope = crewai_event_bus._enter_runtime_scope()
try:
inputs = prepare_kickoff(self, inputs, input_files)
@@ -1256,6 +1259,7 @@ class Crew(FlowTrackable, BaseModel):
finally:
clear_files(self.id)
detach(token)
crewai_event_bus._exit_runtime_scope(runtime_scope)
async def akickoff_for_each(
self,

View File

@@ -80,6 +80,17 @@ def is_replaying() -> bool:
return _replaying.get()
_runtime_state_var: contextvars.ContextVar[RuntimeState | None] = (
contextvars.ContextVar("crewai_runtime_state", default=None)
)
_registered_entity_ids_var: contextvars.ContextVar[set[int] | None] = (
contextvars.ContextVar("crewai_registered_entity_ids", default=None)
)
_runtime_scope_depth: contextvars.ContextVar[int] = contextvars.ContextVar(
"crewai_runtime_scope_depth", default=0
)
class CrewAIEventsBus:
"""Singleton event bus for handling events in CrewAI.
@@ -116,7 +127,6 @@ class CrewAIEventsBus:
_futures_lock: threading.Lock
_executor_initialized: bool
_has_pending_events: bool
_runtime_state: RuntimeState | None
def __new__(cls) -> Self:
"""Create or return the singleton instance.
@@ -151,8 +161,6 @@ class CrewAIEventsBus:
self._console = ConsoleFormatter()
self._executor_initialized = False
self._has_pending_events = False
self._runtime_state: RuntimeState | None = None
self._registered_entity_ids: set[int] = set()
def _ensure_executor_initialized(self) -> None:
"""Lazily initialize the thread pool executor and event loop.
@@ -281,6 +289,51 @@ class CrewAIEventsBus:
"""The RuntimeState currently attached to the bus, if any."""
return self._runtime_state
@property
def _runtime_state(self) -> RuntimeState | None:
return _runtime_state_var.get()
@_runtime_state.setter
def _runtime_state(self, value: RuntimeState | None) -> None:
_runtime_state_var.set(value)
@property
def _registered_entity_ids(self) -> set[int]:
ids = _registered_entity_ids_var.get()
if ids is None:
ids = set()
_registered_entity_ids_var.set(ids)
return ids
@_registered_entity_ids.setter
def _registered_entity_ids(self, value: set[int]) -> None:
_registered_entity_ids_var.set(value)
def reset_runtime_state(self) -> None:
"""Detach the RuntimeState and clear the entity registry."""
self._runtime_state = None
self._registered_entity_ids = set()
def _enter_runtime_scope(self) -> bool:
depth = _runtime_scope_depth.get()
_runtime_scope_depth.set(depth + 1)
if depth != 0:
return False
if _runtime_state_var.get() is None:
from crewai import RuntimeState
if RuntimeState is not None:
_runtime_state_var.set(RuntimeState(root=[]))
_registered_entity_ids_var.set(set())
return True
def _exit_runtime_scope(self, outermost: bool) -> None:
depth = _runtime_scope_depth.get()
_runtime_scope_depth.set(depth - 1 if depth > 0 else 0)
if outermost:
_runtime_state_var.set(None)
_registered_entity_ids_var.set(None)
def register_entity(self, entity: Any) -> None:
"""Add an entity to the RuntimeState, creating it if needed.
@@ -349,6 +402,7 @@ class CrewAIEventsBus:
source: Any,
event: BaseEvent,
handlers: SyncHandlerSet,
state: RuntimeState | None,
) -> None:
"""Call provided synchronous handlers.
@@ -356,8 +410,8 @@ class CrewAIEventsBus:
source: The emitting object
event: The event instance
handlers: Frozenset of sync handlers to call
state: The RuntimeState captured on the emitting context
"""
state = self._runtime_state
errors: list[tuple[SyncHandler, Exception]] = [
(handler, error)
for handler in handlers
@@ -376,6 +430,7 @@ class CrewAIEventsBus:
source: Any,
event: BaseEvent,
handlers: AsyncHandlerSet,
state: RuntimeState | None,
) -> None:
"""Asynchronously call provided async handlers.
@@ -383,8 +438,8 @@ class CrewAIEventsBus:
source: The object that emitted the event
event: The event instance
handlers: Frozenset of async handlers to call
state: The RuntimeState captured on the emitting context
"""
state = self._runtime_state
async def _call(handler: AsyncHandler) -> Any:
if _get_param_count(handler) >= 3:
@@ -399,7 +454,9 @@ class CrewAIEventsBus:
f"[CrewAIEventsBus] Async handler error in {getattr(handler, '__name__', handler)}: {result}"
)
async def _emit_with_dependencies(self, source: Any, event: BaseEvent) -> None:
async def _emit_with_dependencies(
self, source: Any, event: BaseEvent, state: RuntimeState | None
) -> None:
"""Emit an event with dependency-aware handler execution.
Handlers are grouped into execution levels based on their dependencies.
@@ -450,18 +507,18 @@ class CrewAIEventsBus:
if level_sync:
if event_type is LLMStreamChunkEvent:
self._call_handlers(source, event, level_sync)
self._call_handlers(source, event, level_sync, state)
else:
ctx = contextvars.copy_context()
future = self._sync_executor.submit(
ctx.run, self._call_handlers, source, event, level_sync
ctx.run, self._call_handlers, source, event, level_sync, state
)
await asyncio.get_running_loop().run_in_executor(
None, future.result
)
if level_async:
await self._acall_handlers(source, event, level_async)
await self._acall_handlers(source, event, level_async, state)
def _register_source(self, source: Any) -> None:
"""Register the source entity in RuntimeState if applicable."""
@@ -556,21 +613,23 @@ class CrewAIEventsBus:
self._ensure_executor_initialized()
self._has_pending_events = True
state = self._runtime_state
if has_dependencies:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._emit_with_dependencies(source, event),
self._emit_with_dependencies(source, event, state),
self._loop,
)
)
if sync_handlers:
if event_type is LLMStreamChunkEvent:
self._call_handlers(source, event, sync_handlers)
self._call_handlers(source, event, sync_handlers, state)
else:
ctx = contextvars.copy_context()
sync_future = self._sync_executor.submit(
ctx.run, self._call_handlers, source, event, sync_handlers
ctx.run, self._call_handlers, source, event, sync_handlers, state
)
if not async_handlers:
return self._track_future(sync_future)
@@ -578,7 +637,7 @@ class CrewAIEventsBus:
if async_handlers:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._acall_handlers(source, event, async_handlers),
self._acall_handlers(source, event, async_handlers, state),
self._loop,
)
)
@@ -590,21 +649,22 @@ class CrewAIEventsBus:
source: Any,
event: BaseEvent,
handlers: AsyncHandlerSet,
state: RuntimeState | None,
) -> None:
"""Call async handlers with the replaying flag set on the loop thread."""
token = _replaying.set(True)
try:
await self._acall_handlers(source, event, handlers)
await self._acall_handlers(source, event, handlers, state)
finally:
_replaying.reset(token)
async def _emit_with_dependencies_replaying(
self, source: Any, event: BaseEvent
self, source: Any, event: BaseEvent, state: RuntimeState | None
) -> None:
"""Dependency-aware dispatch with the replaying flag set."""
token = _replaying.set(True)
try:
await self._emit_with_dependencies(source, event)
await self._emit_with_dependencies(source, event, state)
finally:
_replaying.reset(token)
@@ -638,12 +698,13 @@ class CrewAIEventsBus:
self._ensure_executor_initialized()
self._has_pending_events = True
state = self._runtime_state
token = _replaying.set(True)
try:
if has_dependencies:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._emit_with_dependencies_replaying(source, event),
self._emit_with_dependencies_replaying(source, event, state),
self._loop,
)
)
@@ -651,7 +712,7 @@ class CrewAIEventsBus:
if sync_handlers:
ctx = contextvars.copy_context()
sync_future = self._sync_executor.submit(
ctx.run, self._call_handlers, source, event, sync_handlers
ctx.run, self._call_handlers, source, event, sync_handlers, state
)
self._track_future(sync_future)
if not async_handlers:
@@ -659,7 +720,9 @@ class CrewAIEventsBus:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._acall_handlers_replaying(source, event, async_handlers),
self._acall_handlers_replaying(
source, event, async_handlers, state
),
self._loop,
)
)
@@ -727,7 +790,9 @@ class CrewAIEventsBus:
async_handlers = self._async_handlers.get(event_type, frozenset())
if async_handlers:
await self._acall_handlers(source, event, async_handlers)
await self._acall_handlers(
source, event, async_handlers, self._runtime_state
)
def register_handler(
self,

View File

@@ -158,7 +158,6 @@ class EventListener(BaseEventListener):
trace_listener.formatter = self.formatter
def setup_listeners(self, crewai_event_bus: CrewAIEventsBus) -> None:
@crewai_event_bus.on(CCEnvEvent)
def on_cc_env(_: Any, event: CCEnvEvent) -> None:
self._telemetry.env_context_span(event.type)

View File

@@ -292,7 +292,7 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(CrewKickoffCompletedEvent)
def on_crew_completed(source: Any, event: CrewKickoffCompletedEvent) -> None:
self._handle_trace_event("crew_kickoff_completed", source, event)
if self.batch_manager.defer_session_finalization:
if self._should_defer_session_finalization():
return
if self._nested_in_flow_execution():
return
@@ -306,7 +306,7 @@ class TraceCollectionListener(BaseEventListener):
@event_bus.on(CrewKickoffFailedEvent)
def on_crew_failed(source: Any, event: CrewKickoffFailedEvent) -> None:
self._handle_trace_event("crew_kickoff_failed", source, event)
if self.batch_manager.defer_session_finalization:
if self._should_defer_session_finalization():
return
if self._nested_in_flow_execution():
return
@@ -734,7 +734,7 @@ class TraceCollectionListener(BaseEventListener):
if not self.batch_manager.is_batch_initialized():
return
# Multi-turn flows defer batch finalization to finalize_session_traces().
if self.batch_manager.defer_session_finalization:
if self._should_defer_session_finalization():
return
self.batch_manager.finalize_batch()
@@ -745,6 +745,15 @@ class TraceCollectionListener(BaseEventListener):
return current_flow_id.get() is not None
def _should_defer_session_finalization(self) -> bool:
"""True when the active trace belongs to a deferred flow session."""
from crewai.flow.flow_context import current_flow_defer_trace_finalization
return (
self.batch_manager.defer_session_finalization
or current_flow_defer_trace_finalization.get()
)
def _flow_owns_trace_batch(self) -> bool:
"""True when an in-flight conversational flow already owns the trace batch."""
if self.batch_manager.batch_owner_type == "flow":
@@ -780,12 +789,17 @@ class TraceCollectionListener(BaseEventListener):
def _try_initialize_flow_batch_from_context(self, event: Any) -> bool:
"""Claim a flow trace batch when an action event fires inside kickoff.
When ``suppress_flow_events=True``, console panels are hidden but
``FlowStartedEvent`` and method lifecycle events still emit; if no
batch exists yet, LLM/tool events must not fall back to implicit crew
batches.
When ``suppress_flow_events=True`` (infrastructure flows such as
``AgentExecutor`` and the memory flows), flow and method lifecycle
events are not emitted, so the batch is claimed from the flow context
(``current_flow_id``) to keep LLM/tool events from falling back to an
implicit crew batch.
"""
from crewai.flow.flow_context import current_flow_id, current_flow_name
from crewai.flow.flow_context import (
current_flow_defer_trace_finalization,
current_flow_id,
current_flow_name,
)
flow_id = current_flow_id.get()
if flow_id is None:
@@ -801,6 +815,8 @@ class TraceCollectionListener(BaseEventListener):
}
self.batch_manager.batch_owner_type = "flow"
self.batch_manager.batch_owner_id = flow_id
if current_flow_defer_trace_finalization.get():
self.batch_manager.defer_session_finalization = True
self._initialize_batch(user_context, execution_metadata)
return True

View File

@@ -1,6 +1,6 @@
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_serializer
from crewai.events.base_events import BaseEvent
@@ -57,6 +57,10 @@ class MethodExecutionFailedEvent(FlowEvent):
model_config = ConfigDict(arbitrary_types_allowed=True)
@field_serializer("error")
def _serialize_error(self, error: Exception) -> str:
return str(error)
class MethodExecutionPausedEvent(FlowEvent):
"""Event emitted when a flow method is paused waiting for human feedback.

View File

@@ -279,6 +279,16 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"""Set state messages."""
self._state.messages = value
@property
def ask_for_human_input(self) -> bool:
"""Compatibility property - returns state ask_for_human_input."""
return self._state.ask_for_human_input # type: ignore[no-any-return]
@ask_for_human_input.setter
def ask_for_human_input(self, value: bool) -> None:
"""Set state ask_for_human_input."""
self._state.ask_for_human_input = value
@start()
def generate_plan(self) -> None:
"""Generate execution plan if planning is enabled.

View File

@@ -1,15 +1,17 @@
"""Conversational graph + helpers as a mixin for ``Flow`` (experimental).
"""Conversational graph + helpers as an experimental Flow extension.
The experimental conversational chat surface lives here as a mixin so that
``crewai.flow.runtime`` stays focused on the execution engine. ``Flow``
inherits from ``_ConversationalMixin``; the methods only register on
subclasses that opt in via ``conversational = True`` (enforced by the
``_conversational_only`` marker + ``FlowMeta`` gating in
``crewai.flow.runtime``).
The conversational chat surface remains experimental and may change before the
v2 graduation path. It lives here so ``crewai.flow.runtime`` can stay focused
on the execution engine. ``crewai.flow.flow`` composes this mixin onto the
public ``Flow`` class for backwards compatibility.
The built-in conversational graph only registers for subclasses that opt in
with ``conversational = True``. Static conversational metadata is projected
into ``FlowDefinition.conversational`` via the Python DSL builder.
Import surface:
- :class:`_ConversationalMixin` — internal; ``Flow`` mixes it in. Users
don't import it directly.
- :class:`_ConversationalMixin` — internal; the public ``Flow`` class
composes it in. Users don't import it directly.
- The data types this mixin uses live in
:mod:`crewai.experimental.conversational`.
"""
@@ -20,7 +22,7 @@ from collections.abc import Callable, Mapping, Sequence
from enum import Enum
import json
import logging
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar, cast
from pydantic import BaseModel, Field, create_model
@@ -44,26 +46,69 @@ from crewai.flow.conversation import (
get_conversation_messages,
receive_user_message as _receive_user_message,
)
from crewai.flow.dsl import listen, router, start
from crewai.flow.dsl import listen, start
from crewai.flow.dsl._utils import _method_action, _set_flow_method_definition
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
from crewai.llms.base_llm import BaseLLM
logger = logging.getLogger(__name__)
class _ConversationalMixin:
"""Built-in conversational graph for ``Flow`` (gated on ``conversational``).
def _iter_condition_labels(condition: Any) -> set[str]:
if isinstance(condition, str):
return {condition}
if isinstance(condition, dict):
labels: set[str] = set()
for value in condition.values():
if isinstance(value, list):
for item in value:
labels.update(_iter_condition_labels(item))
else:
labels.update(_iter_condition_labels(value))
return labels
return set()
Mixed into ``Flow`` so its execution engine (``runtime.py``) stays focused
on running graphs. The methods here only register on subclasses that set
``conversational = True``; non-chat flows see them as inert attributes.
def _conversation_start_router(func: Callable[..., Any]) -> Any:
wrapper = start()(func)
_set_flow_method_definition(
cast(Any, wrapper),
FlowMethodDefinition(do=_method_action(func), start=True, router=True),
)
return wrapper
class _ConversationalMixin:
"""Experimental conversational graph for ``Flow``.
This mixin owns chat behavior and runtime hooks. Non-chat flows see these
methods as inert attributes unless they opt in with ``conversational = True``.
"""
# === EXPERIMENTAL: conversational mode ===
# When ``conversational = True`` on a Flow subclass, this mixin's built-in
# graph registers and ``handle_turn`` / ``chat`` become chat entry points.
conversational: ClassVar[bool] = False
conversational_config: ClassVar[ConversationConfig | None] = None
builtin_routes: ClassVar[tuple[str, ...]] = ("converse", "end")
internal_routes: ClassVar[tuple[str, ...]] = ("answer_from_history",)
builtin_route_descriptions: ClassVar[dict[str, str]] = {
"converse": (
"Ordinary chat, follow-ups, summaries, clarifications, and "
"questions answerable from prior conversation history."
),
"end": ("User signals the conversation is finished (goodbye, exit, done)."),
"answer_from_history": (
"Answer directly from prior conversation history without invoking "
"tools, agents, or custom routes."
),
}
# The metaclass + state attributes referenced below live on ``Flow`` —
# this mixin is never instantiated standalone. These type-only
# declarations exist so static analyzers don't flag attribute access.
@@ -71,22 +116,15 @@ class _ConversationalMixin:
# (otherwise mypy flags "Cannot override instance variable with class
# variable" when Flow declares them as ``ClassVar``).
if TYPE_CHECKING:
conversational: ClassVar[bool]
conversational_config: ClassVar[ConversationConfig | None]
builtin_routes: ClassVar[tuple[str, ...]]
internal_routes: ClassVar[tuple[str, ...]]
builtin_route_descriptions: ClassVar[dict[str, str]]
# Registry ClassVars populated by ``FlowMeta`` at class creation.
_listeners: ClassVar[dict[Any, Any]]
# Instance attrs from ``Flow``.
state: Any
name: str | None
_completed_methods: set[Any]
_method_outputs: list[Any]
_pending_and_listeners: dict[Any, Any]
_pending_events: dict[Any, Any]
_method_call_counts: dict[Any, int]
_is_execution_resuming: bool
_conversation_messages: list[LLMMessage]
_pending_user_message: str | dict[str, Any] | None
_pending_intents: Sequence[str] | None
_pending_intent_llm: str | BaseLLM | None
@@ -97,8 +135,8 @@ class _ConversationalMixin:
def _collapse_to_outcome(
self,
feedback: str,
outcomes: tuple[str, ...],
llm: str | BaseLLM | Any,
outcomes: Sequence[str],
llm: str | BaseLLM,
) -> str:
pass
@@ -108,23 +146,28 @@ class _ConversationalMixin:
def kickoff(self, *args: Any, **kwargs: Any) -> Any:
pass
@start()
@_conversational_only
def conversation_start(self) -> str | None:
"""Internal Flow entrypoint that hands the user message to the router.
@property
def method_outputs(self) -> list[Any]:
pass
In conversational mode, ``Flow.kickoff_async`` runs all ``@start``
methods sequentially and this one is registered last, so any user
``@start`` methods (e.g. permission loading) have already finished
before the returned value triggers ``route_conversation``.
def conversation_start(self) -> str | None:
"""Return the current user message for conversational route selection.
This remains as a plain overridable helper for compatibility. It is not
registered as a Flow method; ``route_conversation`` is the synthetic
built-in start/router that begins a conversational turn.
"""
state = cast(ConversationState, self.state)
return state.current_user_message
@router(conversation_start)
@_conversation_start_router
@_conversational_only
def route_conversation(self) -> str:
"""Route the current turn to a listener label."""
if "conversation_start" not in {
str(method_name) for method_name in self._completed_methods
}:
self.conversation_start()
state = cast(ConversationState, self.state)
context = self.build_router_context()
previous_intent = state.last_intent
@@ -238,8 +281,8 @@ class _ConversationalMixin:
state = cast(ConversationState, self.state)
sid = session_id or state.id
# Stash the pending turn so ``_apply_pending_conversational_turn``
# picks it up AFTER persist restore.
# Stash the pending turn so the kickoff extension hook picks it up
# after persist restore.
self._pending_user_message = message
self._pending_intents = list(intents) if intents else None
self._pending_intent_llm = intent_llm
@@ -286,7 +329,7 @@ class _ConversationalMixin:
callers can customize prompts or exercise the loop without patching
builtins.
"""
if not getattr(type(self), "conversational", False):
if not self._is_conversational_enabled():
raise ValueError("Flow.chat() is only available on conversational flows")
exit_set = {command.lower() for command in exit_commands}
@@ -491,14 +534,14 @@ class _ConversationalMixin:
**extra: Any,
) -> None:
"""Append a message to conversation history (legacy ChatState path)."""
_append_conversation_message(cast("Flow[Any]", self), role, content, **extra)
_append_conversation_message(cast(Any, self), role, content, **extra)
@property
def conversation_messages(self) -> list[LLMMessage]:
"""Message history from state, coerced to LLM-shaped dicts."""
return [
message_to_llm_dict(message)
for message in get_conversation_messages(cast("Flow[Any]", self))
for message in get_conversation_messages(cast(Any, self))
]
def receive_user_message(
@@ -514,7 +557,7 @@ class _ConversationalMixin:
``state.messages`` and preserve ``last_intent`` across turns.
Non-conversational flows fall through to the legacy helper.
"""
if self.conversational:
if self._is_conversational_enabled():
state = cast(ConversationState, self.state)
state.messages.append(ConversationMessage(role="user", content=text))
self._emit_conversation_message_added(
@@ -535,9 +578,7 @@ class _ConversationalMixin:
return intent
return text
return _receive_user_message(
cast("Flow[Any]", self), text, outcomes=outcomes, llm=llm
)
return _receive_user_message(cast(Any, self), text, outcomes=outcomes, llm=llm)
def classify_intent(
self,
@@ -561,27 +602,104 @@ class _ConversationalMixin:
def _conversation_config(self) -> ConversationConfig | None:
return getattr(type(self), "conversational_config", None)
@property
def _conversation_definition(self) -> Any | None:
return self._conversation_flow_definition().conversational
def _conversation_flow_definition(self) -> Any:
flow_definition = getattr(type(self), "flow_definition", None)
if not callable(flow_definition):
raise AttributeError(
f"{type(self).__name__} does not expose flow_definition()"
)
return flow_definition()
@classmethod
def _conversational_definition(cls) -> Any | None:
flow_definition = getattr(cls, "flow_definition", None)
if not callable(flow_definition):
return None
return flow_definition().conversational
@classmethod
def _is_conversational(cls) -> bool:
definition = cls._conversational_definition()
return bool(definition and definition.enabled)
def _is_conversational_enabled(self) -> bool:
definition = self._conversation_definition
return bool(definition and definition.enabled)
def _initialize_runtime_extension_attrs(self) -> None:
if not isinstance(getattr(self, "_conversation_messages", None), list):
object.__setattr__(self, "_conversation_messages", [])
if not hasattr(self, "_pending_user_message"):
object.__setattr__(self, "_pending_user_message", None)
if not hasattr(self, "_pending_intents"):
object.__setattr__(self, "_pending_intents", None)
if not hasattr(self, "_pending_intent_llm"):
object.__setattr__(self, "_pending_intent_llm", None)
def _create_default_extension_state(self) -> ConversationState | None:
initial_state_t = getattr(self, "_initial_state_t", None)
if type(self)._is_conversational() and (
not hasattr(self, "_initial_state_t")
or isinstance(initial_state_t, TypeVar)
):
return ConversationState()
return None
def _should_apply_pending_kickoff_context(self) -> bool:
return (
type(self)._is_conversational() and self._pending_user_message is not None
)
def _apply_pending_kickoff_context(self) -> None:
self._apply_pending_conversational_turn()
def _order_start_methods_for_kickoff(
self,
start_methods: list[Any],
) -> tuple[list[Any], bool]:
if not type(self)._is_conversational():
return start_methods, False
route_conversation = "route_conversation"
if route_conversation not in {str(method) for method in start_methods}:
return start_methods, False
ordered_starts = [
method for method in start_methods if str(method) != route_conversation
]
ordered_starts.append(
next(
method for method in start_methods if str(method) == route_conversation
)
)
return ordered_starts, True
def _should_defer_trace_finalization(self) -> bool:
"""Whether per-turn ``FlowFinished`` + ``finalize_batch`` should be skipped.
True when either:
- ``flow.defer_trace_finalization`` is set on the instance, OR
- the class-level ``ConversationConfig.defer_trace_finalization``
on a conversational subclass is True.
- the static conversational definition enables deferred finalization.
Either source enables the deferred-session pattern. The caller
eventually invokes ``finalize_session_traces()`` to close the batch.
"""
if getattr(self, "defer_trace_finalization", False):
return True
config = self._conversation_config
return bool(config and config.defer_trace_finalization)
definition = self._conversation_definition
return bool(
definition and definition.enabled and definition.defer_trace_finalization
)
def _reset_turn_execution_state(self) -> None:
"""Clear per-execution tracking so the next turn re-runs the graph."""
self._completed_methods.clear()
self._method_outputs.clear()
self._pending_and_listeners.clear()
self._pending_events.clear()
self._method_call_counts.clear()
self._clear_or_listeners()
self._is_execution_resuming = False
@@ -733,11 +851,12 @@ class _ConversationalMixin:
router_config: RouterConfig | None,
) -> dict[str, str]:
label_to_method: dict[str, str] = {}
for listener_name, condition in self._listeners.items():
if isinstance(condition, tuple):
_, trigger_labels = condition
for trigger_label in trigger_labels:
label_to_method.setdefault(str(trigger_label), str(listener_name))
flow_definition = self._conversation_flow_definition()
for listener_name, method_definition in flow_definition.methods.items():
if method_definition.listen is None or method_definition.router:
continue
for trigger_label in _iter_condition_labels(method_definition.listen):
label_to_method.setdefault(trigger_label, listener_name)
routes = self._effective_routes(router_config)
overrides = (
@@ -788,21 +907,31 @@ class _ConversationalMixin:
def _valid_route_labels(self) -> set[str]:
labels: set[str] = set()
for condition in self._listeners.values():
if isinstance(condition, tuple):
_, methods = condition
labels.update(str(method) for method in methods)
flow_definition = self._conversation_flow_definition()
for method_definition in flow_definition.methods.values():
if method_definition.listen is None or method_definition.router:
continue
labels.update(_iter_condition_labels(method_definition.listen))
return labels
def _effective_routes(self, router_config: RouterConfig | None = None) -> set[str]:
custom_routes = set(router_config.routes or ()) if router_config else set()
definition = self._conversation_definition
builtin_routes = (
tuple(definition.builtin_routes)
if definition is not None
else self.builtin_routes
)
internal_routes = (
tuple(definition.internal_routes)
if definition is not None
else self.internal_routes
)
if not custom_routes:
custom_routes = (
self._valid_route_labels()
- set(self.builtin_routes)
- set(self.internal_routes)
self._valid_route_labels() - set(builtin_routes) - set(internal_routes)
)
return custom_routes | set(self.builtin_routes)
return custom_routes | set(builtin_routes)
def _default_conversation_llm(self) -> Any | None:
config = self._conversation_config
@@ -908,7 +1037,8 @@ class _ConversationalMixin:
# of warning about an empty scope stack.
started_id = getattr(self, "_deferred_flow_started_event_id", None)
if started_id:
last_output = self._method_outputs[-1] if self._method_outputs else None
method_outputs = self.method_outputs
last_output = method_outputs[-1] if method_outputs else None
restore_event_scope(((started_id, "flow_started"),))
try:
crewai_event_bus.emit(
@@ -931,12 +1061,15 @@ class _ConversationalMixin:
trace_listener = TraceCollectionListener()
batch_manager = trace_listener.batch_manager
if batch_manager.batch_owner_type == "flow":
if trace_listener.first_time_handler.is_first_time:
trace_listener.first_time_handler.mark_events_collected()
trace_listener.first_time_handler.handle_execution_completion()
else:
batch_manager.finalize_batch()
try:
if batch_manager.batch_owner_type == "flow":
if trace_listener.first_time_handler.is_first_time:
trace_listener.first_time_handler.mark_events_collected()
trace_listener.first_time_handler.handle_execution_completion()
else:
batch_manager.finalize_batch()
finally:
batch_manager.defer_session_finalization = False
__all__ = ["_ConversationalMixin"]

View File

@@ -0,0 +1,48 @@
"""Static conversational Flow definition models.
This module is part of the serializable Flow Definition contract. It should
only contain static data shapes. Experimental conversational runtime behavior
continues to live in ``crewai.experimental.conversational_mixin``.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class FlowConversationalRouterDefinition(BaseModel):
"""Static conversational router configuration."""
prompt: str | None = None
response_format: Any = None
llm: Any = None
routes: list[str] | None = None
route_descriptions: dict[str, str] | None = None
default_intent: str | None = "converse"
fallback_intent: str | None = "converse"
intent_field: str = "intent"
class FlowConversationalDefinition(BaseModel):
"""Static conversational Flow configuration."""
enabled: bool = False
system_prompt: str | None = None
llm: Any = None
router: FlowConversationalRouterDefinition | None = None
answer_from_history_prompt: str | None = None
default_intents: list[str] | None = None
intent_llm: Any = None
answer_from_history_llm: Any = None
visible_agent_outputs: list[str] | Literal["all"] | None = None
defer_trace_finalization: bool = True
builtin_routes: list[str] = Field(default_factory=lambda: ["converse", "end"])
internal_routes: list[str] = Field(default_factory=lambda: ["answer_from_history"])
__all__ = [
"FlowConversationalDefinition",
"FlowConversationalRouterDefinition",
]

View File

@@ -15,10 +15,7 @@ from crewai.flow.dsl._human_feedback import (
from crewai.flow.dsl._listen import listen
from crewai.flow.dsl._router import router
from crewai.flow.dsl._start import start
from crewai.flow.dsl._utils import (
build_flow_definition as build_flow_definition,
extract_flow_definition as extract_flow_definition,
)
from crewai.flow.dsl._utils import build_flow_definition as build_flow_definition
__all__ = [

View File

@@ -1,12 +1,4 @@
"""Flow DSL condition primitives.
Type guards, the public ``or_`` / ``and_`` combinators, and the conversions
between runtime conditions, normalized conditions, and the
``FlowDefinitionCondition`` shape stored on a :class:`FlowDefinition`. These are
the lower layer of the DSL: the decorators and the definition builder
(``_utils``) build on top of them, so this module imports nothing from its
siblings.
"""
"""Flow DSL condition primitives."""
from __future__ import annotations
@@ -20,268 +12,75 @@ from crewai.flow.dsl._types import FlowTrigger
from crewai.flow.flow_definition import FlowDefinitionCondition
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowConditions,
SimpleFlowCondition,
FlowConditionType,
)
from crewai.flow.types import FlowMethodName
def _is_non_string_sequence(value: Any) -> bool:
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]:
"""Check if the object is a ``(condition_type, methods)`` tuple."""
return (
isinstance(obj, tuple)
and len(obj) == 2
and isinstance(obj[0], str)
and isinstance(obj[1], list)
)
def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]:
"""Check if the object matches the FlowCondition structure."""
if not isinstance(obj, dict):
return False
type_value = obj.get("type")
if type_value not in ("AND", "OR"):
return False
if "conditions" in obj:
conditions = obj["conditions"]
if not _is_non_string_sequence(conditions):
return False
for cond in conditions:
if not (
isinstance(cond, str)
or (isinstance(cond, dict) and is_flow_condition_dict(cond))
):
return False
if "methods" in obj:
methods = obj["methods"]
if not (
_is_non_string_sequence(methods)
and all(isinstance(m, str) for m in methods)
):
return False
allowed_keys = {"type", "conditions", "methods"}
if not set(obj).issubset(allowed_keys):
return False
return True
def _method_reference_name(value: Any) -> FlowMethodName | None:
name = getattr(value, "__name__", None)
if callable(value) and isinstance(name, str):
return FlowMethodName(name)
return None
def _normalize_condition(
condition: FlowConditions | FlowCondition | str,
) -> FlowCondition:
if isinstance(condition, str):
return {"type": OR_CONDITION, "conditions": [FlowMethodName(condition)]}
if is_flow_condition_dict(condition):
if "conditions" in condition:
return condition
if "methods" in condition:
normalized_methods: list[str | FlowMethodName | FlowCondition] = list(
condition["methods"]
)
return {"type": condition["type"], "conditions": normalized_methods}
return condition
if _is_non_string_sequence(condition) and all(
isinstance(item, str) or is_flow_condition_dict(item) for item in condition
):
return {"type": OR_CONDITION, "conditions": condition}
raise ValueError(f"Cannot normalize condition: {condition}")
def _extract_all_methods_recursive(
condition: str | FlowCondition | dict[str, Any] | list[Any],
flow: Any | None = None,
) -> list[FlowMethodName]:
if isinstance(condition, str):
if flow is not None:
if condition in flow._methods:
return [FlowMethodName(condition)]
return []
return [FlowMethodName(condition)]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
methods = []
for sub_cond in normalized.get("conditions", []):
methods.extend(_extract_all_methods_recursive(sub_cond, flow))
return methods
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods_recursive(item, flow))
return methods
return []
def _extract_all_methods(
condition: str | FlowCondition | dict[str, Any] | list[Any],
) -> list[FlowMethodName]:
if isinstance(condition, str):
return [FlowMethodName(condition)]
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
cond_type = normalized.get("type", OR_CONDITION)
if cond_type == AND_CONDITION:
return [
FlowMethodName(sub_cond)
for sub_cond in normalized.get("conditions", [])
if isinstance(sub_cond, str)
]
return []
if isinstance(condition, list):
methods = []
for item in condition:
methods.extend(_extract_all_methods(item))
return methods
return []
def _condition_trigger(condition: FlowTrigger) -> FlowMethodName | FlowCondition:
if isinstance(condition, str):
return FlowMethodName(condition)
if is_flow_condition_dict(condition):
return condition
method_name = _method_reference_name(condition)
if method_name is not None:
return method_name
raise ValueError("Invalid condition")
def _condition_triggers(
conditions: Sequence[FlowTrigger],
error_message: str,
) -> FlowConditions:
try:
return [_condition_trigger(condition) for condition in conditions]
except ValueError as exc:
raise ValueError(error_message) from exc
def _definition_condition_from_runtime(condition: Any) -> FlowDefinitionCondition:
if isinstance(condition, str):
return str(condition)
method_name = _method_reference_name(condition)
if method_name is not None:
return str(method_name)
if is_flow_condition_dict(condition):
normalized = _normalize_condition(condition)
key = "and" if normalized.get("type") == AND_CONDITION else "or"
return {
key: [
_definition_condition_from_runtime(sub_condition)
for sub_condition in normalized.get("conditions", [])
]
}
if isinstance(condition, list):
return {"or": [_definition_condition_from_runtime(item) for item in condition]}
return str(condition)
_CONDITION_TYPES = (AND_CONDITION, OR_CONDITION)
def or_(*triggers: FlowTrigger) -> FlowCondition:
"""Combine multiple triggers with OR logic for flow control.
Creates a condition that is satisfied when any of the specified triggers
are met. This is used with @start, @listen, or @router decorators to create
complex triggering conditions.
Args:
triggers: Route labels, method references, or existing conditions
returned by or_() / and_().
Returns:
A condition dictionary with format {"type": "OR", "conditions": list_of_triggers}.
Raises:
ValueError: If a trigger format is invalid.
Examples:
>>> @listen(or_("success", "timeout"))
>>> def handle_completion(self):
... pass
>>> @listen(or_(and_("step1", "step2"), "step3"))
>>> def handle_nested(self):
... pass
"""
processed_triggers = _condition_triggers(triggers, "Invalid trigger in or_()")
return {"type": OR_CONDITION, "conditions": processed_triggers}
"""Return a condition that fires when any trigger fires."""
return _condition_tree(OR_CONDITION, triggers)
def and_(*triggers: FlowTrigger) -> FlowCondition:
"""Combine multiple triggers with AND logic for flow control.
Creates a condition that is satisfied only when all specified triggers
are met. This is used with @start, @listen, or @router decorators to create
complex triggering conditions.
Args:
triggers: Route labels, method references, or existing conditions
returned by or_() / and_().
Returns:
A condition dictionary with format {"type": "AND", "conditions": list_of_conditions}
where each condition can be a route label, method name, or nested condition.
Raises:
ValueError: If any trigger is invalid.
Examples:
>>> @listen(and_("validated", "processed"))
>>> def handle_complete_data(self):
... pass
>>> @listen(and_(or_("step1", "step2"), "step3"))
>>> def handle_nested(self):
... pass
"""
processed_triggers = _condition_triggers(triggers, "Invalid trigger in and_()")
return {"type": AND_CONDITION, "conditions": processed_triggers}
"""Return a condition that fires after all triggers fire."""
return _condition_tree(AND_CONDITION, triggers)
def _runtime_condition_from_definition(
condition: FlowDefinitionCondition,
) -> FlowMethodName | FlowCondition:
if isinstance(condition, str):
return FlowMethodName(condition)
if is_flow_condition_dict(condition):
return condition
def _trigger_name(value: Any) -> str | None:
if isinstance(value, str):
return value
if "and" in condition:
return {
"type": AND_CONDITION,
"conditions": [
_runtime_condition_from_definition(item)
for item in condition.get("and", [])
],
}
name = getattr(value, "__name__", None)
if callable(value) and isinstance(name, str):
return name
return None
def _is_condition(value: Any) -> TypeIs[FlowCondition]:
return (
isinstance(value, dict)
and set(value) == {"type", "conditions"}
and value["type"] in _CONDITION_TYPES
and isinstance(value["conditions"], list)
and all(
_trigger_name(condition) is not None or _is_condition(condition)
for condition in value["conditions"]
)
)
def _coerce_trigger(trigger: FlowTrigger) -> str | FlowCondition:
name = _trigger_name(trigger)
if name is not None:
return name
if _is_condition(trigger):
return trigger
raise ValueError("Invalid condition")
def _condition_tree(
condition_type: FlowConditionType,
triggers: Sequence[FlowTrigger],
) -> FlowCondition:
return {
"type": OR_CONDITION,
"conditions": [
_runtime_condition_from_definition(item) for item in condition.get("or", [])
],
"type": condition_type,
"conditions": [_coerce_trigger(trigger) for trigger in triggers],
}
def _runtime_listener_condition_from_definition(
condition: FlowDefinitionCondition,
) -> SimpleFlowCondition | FlowCondition:
runtime_condition = _runtime_condition_from_definition(condition)
if isinstance(runtime_condition, str):
return (OR_CONDITION, [FlowMethodName(str(runtime_condition))])
return runtime_condition
def _to_definition_condition(condition: FlowTrigger) -> FlowDefinitionCondition:
trigger = _coerce_trigger(condition)
if isinstance(trigger, str):
return trigger
key = trigger["type"].lower()
return {
key: [
_to_definition_condition(sub_condition)
for sub_condition in trigger["conditions"]
]
}

View File

@@ -3,11 +3,10 @@ from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any, TypeVar
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.human_feedback import (
HumanFeedbackConfig,
HumanFeedbackResult,
_build_human_feedback_runtime_decorator,
_validate_human_feedback_options,
)
@@ -21,40 +20,6 @@ F = TypeVar("F", bound=Callable[..., Any])
__all__ = ["HumanFeedbackResult", "human_feedback"]
def _stamp_human_feedback_metadata(
wrapper: Any,
func: Callable[..., Any],
config: HumanFeedbackConfig,
) -> None:
for attr in [
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__trigger_condition__",
"__is_flow_method__",
"__flow_persistence_config__",
"__is_router__",
"__router_emit__",
"__flow_method_definition__",
]:
if hasattr(func, attr):
setattr(wrapper, attr, getattr(func, attr))
wrapper.__human_feedback_config__ = config
wrapper.__is_flow_method__ = True
if config.emit:
wrapper.__is_router__ = True
wrapper.__router_emit__ = list(config.emit)
fragment = getattr(wrapper, "__flow_method_definition__", None)
if isinstance(fragment, FlowMethodDefinition):
wrapper.__flow_method_definition__ = fragment.model_copy(
update={"router": True, "emit": list(config.emit)}
)
wrapper._human_feedback_llm = config.llm
def human_feedback(
message: str,
emit: Sequence[str] | None = None,
@@ -66,21 +31,18 @@ def human_feedback(
learn_source: str = "hitl",
learn_strict: bool = False,
) -> Callable[[F], F]:
"""Decorator for Flow methods that require human feedback."""
runtime_decorator = _build_human_feedback_runtime_decorator(
message=message,
emit=emit,
llm=llm,
default_outcome=default_outcome,
metadata=metadata,
provider=provider,
learn=learn,
learn_source=learn_source,
learn_strict=learn_strict,
"""Decorator for Flow methods that require human feedback.
The decorator is a pure metadata stamper: it records the feedback
configuration on the method, and the Flow engine collects and routes
feedback after the method completes, driven by the flow's definition.
"""
_validate_human_feedback_options(
emit=emit, llm=llm, default_outcome=default_outcome
)
config = HumanFeedbackConfig(
message=message,
emit=emit,
emit=list(emit) if emit is not None else None,
llm=llm,
default_outcome=default_outcome,
metadata=metadata,
@@ -91,8 +53,7 @@ def human_feedback(
)
def decorator(func: F) -> F:
wrapper = runtime_decorator(func)
_stamp_human_feedback_metadata(wrapper, func, config)
return wrapper
func.__human_feedback_config__ = config # type: ignore[attr-defined]
return func
return decorator

View File

@@ -3,13 +3,13 @@ from __future__ import annotations
from collections.abc import Callable
from typing import cast
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
from crewai.flow.dsl._conditions import _to_definition_condition
from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_method_action,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import ListenMethod
@@ -47,9 +47,11 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(listen=_definition_condition_from_runtime(condition)),
FlowMethodDefinition(
do=_method_action(func),
listen=_to_definition_condition(condition),
),
)
_set_trigger_metadata(wrapper, condition)
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -14,13 +14,13 @@ from typing import (
get_type_hints,
)
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
from crewai.flow.dsl._conditions import _to_definition_condition
from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_method_action,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import RouterMethod
@@ -149,18 +149,12 @@ def router(
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
listen=_definition_condition_from_runtime(condition),
do=_method_action(func),
listen=_to_definition_condition(condition),
router=True,
emit=router_events or None,
),
)
_set_trigger_metadata(wrapper, condition)
if emit is not None:
wrapper.__router_emit__ = router_events
elif router_events:
wrapper.__router_emit__ = router_events
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -3,13 +3,13 @@ from __future__ import annotations
from collections.abc import Callable
from typing import cast
from crewai.flow.dsl._conditions import _definition_condition_from_runtime
from crewai.flow.dsl._conditions import _to_definition_condition
from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_method_action,
_set_flow_method_definition,
_set_trigger_metadata,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import StartMethod
@@ -54,16 +54,17 @@ def start(
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
wrapper = StartMethod(func)
if condition is not None:
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
start=_definition_condition_from_runtime(condition)
_set_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),
start=(
_to_definition_condition(condition)
if condition is not None
else True
),
)
_set_trigger_metadata(wrapper, condition)
else:
_set_flow_method_definition(wrapper, FlowMethodDefinition(start=True))
),
)
return wrapper
return cast(FlowMethodDecorator, decorator)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from collections.abc import Sequence
import json
import logging
from typing import Any, ParamSpec, TypeVar
@@ -8,32 +7,23 @@ from typing import Any, ParamSpec, TypeVar
from pydantic import BaseModel
from typing_extensions import TypeIs
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.flow.dsl._conditions import (
_definition_condition_from_runtime,
_extract_all_methods,
_method_reference_name,
_runtime_listener_condition_from_definition,
is_flow_condition_dict,
)
from crewai.flow.dsl._types import FlowTrigger
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowCodeActionDefinition,
FlowConfigDefinition,
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
FlowDefinition,
FlowDefinitionCondition,
FlowDefinitionDiagnostic,
FlowHumanFeedbackDefinition,
FlowMethodDefinition,
FlowPersistenceDefinition,
FlowStateDefinition,
_object_ref,
)
from crewai.flow.flow_wrappers import (
FlowMethod,
ListenMethod,
RouterMethod,
StartMethod,
)
from crewai.flow.types import FlowMethodName
P = ParamSpec("P")
@@ -42,17 +32,17 @@ R = TypeVar("R")
logger = logging.getLogger(__name__)
_FLOW_METHOD_DEFINITION_ATTR = "__flow_method_definition__"
_FLOW_METHOD_METADATA_ATTRS = [
"__conversational_only__",
"__flow_method_definition__",
"__flow_persistence_config__",
"__human_feedback_config__",
]
def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]:
"""Check if the object carries Flow method wrapper metadata."""
return (
hasattr(obj, "__is_flow_method__")
or hasattr(obj, "__is_start_method__")
or hasattr(obj, "__trigger_methods__")
or hasattr(obj, "__is_router__")
or hasattr(obj, _FLOW_METHOD_DEFINITION_ATTR)
)
return hasattr(obj, _FLOW_METHOD_DEFINITION_ATTR)
def _should_include_flow_method(flow_class: type, method: Any) -> bool:
@@ -61,44 +51,44 @@ def _should_include_flow_method(flow_class: type, method: Any) -> bool:
return True
def _flow_method_names(values: Sequence[Any]) -> list[FlowMethodName]:
return [FlowMethodName(str(value)) for value in values]
def _is_conversational_flow(flow_class: type) -> bool:
return bool(getattr(flow_class, "conversational", False))
def _set_trigger_metadata(
wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R],
condition: FlowTrigger,
) -> None:
if isinstance(condition, str):
wrapper.__trigger_methods__ = [FlowMethodName(condition)]
wrapper.__condition_type__ = OR_CONDITION
return
def _get_inherited_conversational_method(
flow_class: type,
attr_name: str,
) -> Any | None:
if not _is_conversational_flow(flow_class):
return None
if is_flow_condition_dict(condition):
if "conditions" in condition:
wrapper.__trigger_condition__ = condition
wrapper.__trigger_methods__ = _extract_all_methods(condition)
wrapper.__condition_type__ = condition["type"]
return
if "methods" in condition:
wrapper.__trigger_methods__ = _flow_method_names(condition["methods"])
wrapper.__condition_type__ = condition["type"]
return
raise ValueError("Condition dict must contain 'conditions' or 'methods'")
for base in flow_class.__mro__[1:]:
inherited = base.__dict__.get(attr_name)
if inherited is None:
continue
if getattr(inherited, "__conversational_only__", False) and is_flow_method(
inherited
):
return inherited
return None
method_name = _method_reference_name(condition)
if method_name is not None:
wrapper.__trigger_methods__ = [method_name]
wrapper.__condition_type__ = OR_CONDITION
return
raise ValueError(
"Condition must be a method, string, or a result of or_() or and_()"
)
def _stamp_inherited_conversational_metadata(
method: Any,
inherited: Any,
) -> Any:
for attr in _FLOW_METHOD_METADATA_ATTRS:
if hasattr(inherited, attr):
setattr(method, attr, getattr(inherited, attr))
return method
def _method_action(method: Any) -> FlowActionDefinition:
return FlowCodeActionDefinition(ref=f"{method.__module__}:{method.__qualname__}")
def _set_flow_method_definition(
wrapper: StartMethod[P, R] | ListenMethod[P, R] | RouterMethod[P, R],
wrapper: FlowMethod[P, R],
definition: FlowMethodDefinition,
) -> None:
setattr(wrapper, _FLOW_METHOD_DEFINITION_ATTR, definition)
@@ -113,13 +103,6 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None:
return None
def _object_ref(value: Any) -> str:
target = value if isinstance(value, type) else type(value)
module = getattr(target, "__module__", "")
qualname = getattr(target, "__qualname__", getattr(target, "__name__", ""))
return f"{module}:{qualname}" if module and qualname else repr(value)
def _is_json_serializable(value: Any) -> bool:
try:
json.dumps(value)
@@ -190,6 +173,8 @@ def _build_state_definition(
from pydantic import BaseModel as PydanticBaseModel
state_value = getattr(flow_class, "_initial_state_t", None)
if isinstance(state_value, TypeVar):
state_value = None
initial_state = getattr(flow_class, "initial_state", None)
if initial_state is not None:
state_value = initial_state
@@ -225,70 +210,25 @@ def _build_config_definition(
) -> FlowConfigDefinition:
config_field_names = set(FlowConfigDefinition.model_fields)
field_defaults = {
name: field.default
name: field.get_default(call_default_factory=True)
for name, field in getattr(flow_class, "model_fields", {}).items()
if name in config_field_names
}
values: dict[str, Any] = {}
for field_name, default in field_defaults.items():
value = getattr(flow_class, field_name, default)
values[field_name] = _serialize_static_value(
value, diagnostics, f"config.{field_name}"
)
if field_name == "input_provider":
# A string value is already a ref; only live objects degrade.
values[field_name] = (
value if value is None or isinstance(value, str) else _object_ref(value)
)
else:
values[field_name] = _serialize_static_value(
value, diagnostics, f"config.{field_name}"
)
return FlowConfigDefinition(**values)
def _condition_from_method_metadata(method: Any) -> FlowDefinitionCondition | None:
trigger_condition = getattr(method, "__trigger_condition__", None)
if trigger_condition is not None:
return _definition_condition_from_runtime(trigger_condition)
trigger_methods = getattr(method, "__trigger_methods__", None)
if trigger_methods is None:
return None
condition_type = getattr(method, "__condition_type__", OR_CONDITION)
method_names = [str(method_name) for method_name in trigger_methods]
if condition_type == AND_CONDITION:
return {"and": method_names}
if len(method_names) == 1:
return method_names[0]
return {"or": method_names}
def _flow_method_definition_from_legacy_metadata(method: Any) -> FlowMethodDefinition:
is_start = bool(getattr(method, "__is_start_method__", False))
is_router = bool(getattr(method, "__is_router__", False))
condition = _condition_from_method_metadata(method)
if not is_start:
start_value: bool | FlowDefinitionCondition | None = None
elif condition is not None:
start_value = condition
else:
start_value = True
definition = FlowMethodDefinition(
start=start_value,
listen=condition if not is_start else None,
router=is_router,
)
router_emit = getattr(method, "__router_emit__", None)
if router_emit:
definition.emit = [str(value) for value in router_emit]
return definition
def _definition_trigger_condition(
method_definition: FlowMethodDefinition,
) -> FlowDefinitionCondition | None:
if method_definition.listen is not None:
return method_definition.listen
if isinstance(method_definition.start, (str, dict)):
return method_definition.start
return None
def _build_human_feedback_definition(
method: Any,
diagnostics: list[FlowDefinitionDiagnostic],
@@ -301,38 +241,123 @@ def _build_human_feedback_definition(
return FlowHumanFeedbackDefinition(
message=str(config.message),
emit=[str(value) for value in emit] if emit is not None else None,
llm=_serialize_static_value(
getattr(config, "llm", None), diagnostics, f"{path}.llm"
),
# llm and provider stay live: the engine consumes them in-process and
# the contract degrades them to serializable forms at JSON dump time.
llm=getattr(config, "llm", None),
default_outcome=getattr(config, "default_outcome", None),
metadata=_serialize_static_value(
getattr(config, "metadata", None), diagnostics, f"{path}.metadata"
),
provider=_serialize_static_value(
getattr(config, "provider", None), diagnostics, f"{path}.provider"
),
provider=getattr(config, "provider", None),
learn=bool(getattr(config, "learn", False)),
learn_source=str(getattr(config, "learn_source", "hitl")),
learn_strict=bool(getattr(config, "learn_strict", False)),
)
def _build_persistence_definition(
value: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowPersistenceDefinition | None:
def _build_persistence_definition(value: Any) -> FlowPersistenceDefinition | None:
config = getattr(value, "__flow_persistence_config__", None)
if config is None:
return None
persistence = getattr(config, "persistence", None)
verbose = bool(getattr(config, "verbose", False))
return FlowPersistenceDefinition(
enabled=True,
verbose=verbose,
persistence=_serialize_static_value(
persistence, diagnostics, f"{path}.persistence"
verbose=bool(getattr(config, "verbose", False)),
# The backend stays live: the engine persists through the exact
# instance the user configured; the contract degrades it to a
# serialized config at JSON dump time.
persistence=getattr(config, "persistence", None),
)
def _build_conversational_router_definition(
router_config: Any,
diagnostics: list[FlowDefinitionDiagnostic],
path: str,
) -> FlowConversationalRouterDefinition | None:
if router_config is None:
return None
routes = getattr(router_config, "routes", None)
return FlowConversationalRouterDefinition(
prompt=getattr(router_config, "prompt", None),
response_format=_serialize_static_value(
getattr(router_config, "response_format", None),
diagnostics,
f"{path}.response_format",
),
llm=_serialize_static_value(
getattr(router_config, "llm", None), diagnostics, f"{path}.llm"
),
routes=[str(route) for route in routes] if routes is not None else None,
route_descriptions=getattr(router_config, "route_descriptions", None),
default_intent=getattr(router_config, "default_intent", "converse"),
fallback_intent=getattr(router_config, "fallback_intent", "converse"),
intent_field=str(getattr(router_config, "intent_field", "intent")),
)
def _build_conversational_definition(
flow_class: type,
diagnostics: list[FlowDefinitionDiagnostic],
) -> FlowConversationalDefinition | None:
if not _is_conversational_flow(flow_class):
return None
config = getattr(flow_class, "conversational_config", None)
builtin_routes = getattr(flow_class, "builtin_routes", ("converse", "end"))
internal_routes = getattr(
flow_class,
"internal_routes",
("answer_from_history",),
)
if config is None:
return FlowConversationalDefinition(
enabled=True,
builtin_routes=[str(route) for route in builtin_routes],
internal_routes=[str(route) for route in internal_routes],
)
default_intents = getattr(config, "default_intents", None)
visible_agent_outputs = getattr(config, "visible_agent_outputs", None)
return FlowConversationalDefinition(
enabled=True,
system_prompt=getattr(config, "system_prompt", None),
llm=_serialize_static_value(
getattr(config, "llm", None), diagnostics, "conversational.llm"
),
router=_build_conversational_router_definition(
getattr(config, "router", None),
diagnostics,
"conversational.router",
),
answer_from_history_prompt=getattr(config, "answer_from_history_prompt", None),
default_intents=(
[str(intent) for intent in default_intents]
if default_intents is not None
else None
),
intent_llm=_serialize_static_value(
getattr(config, "intent_llm", None),
diagnostics,
"conversational.intent_llm",
),
answer_from_history_llm=_serialize_static_value(
getattr(config, "answer_from_history_llm", None),
diagnostics,
"conversational.answer_from_history_llm",
),
visible_agent_outputs=(
"all"
if visible_agent_outputs == "all"
else [str(output) for output in visible_agent_outputs]
if visible_agent_outputs is not None
else None
),
defer_trace_finalization=bool(
getattr(config, "defer_trace_finalization", True)
),
builtin_routes=[str(route) for route in builtin_routes],
internal_routes=[str(route) for route in internal_routes],
)
@@ -343,12 +368,11 @@ def _build_method_definition(
) -> FlowMethodDefinition:
fragment = _get_flow_method_definition(method)
if fragment is None:
method_definition = _flow_method_definition_from_legacy_metadata(method)
method_definition = FlowMethodDefinition(do=_method_action(method))
else:
method_definition = fragment.model_copy(deep=True)
if bool(getattr(method, "__is_router__", False)):
method_definition.router = True
method_definition = fragment.model_copy(
deep=True, update={"do": _method_action(method)}
)
human_feedback = _build_human_feedback_definition(
method, diagnostics, f"{path}.human_feedback"
@@ -359,21 +383,14 @@ def _build_method_definition(
method_definition.router = True
method_definition.emit = None
method_definition.persist = _build_persistence_definition(
method, diagnostics, f"{path}.persist"
)
router_emit = getattr(method, "__router_emit__", None)
if router_emit and not (human_feedback and human_feedback.emit):
if not method_definition.emit:
method_definition.emit = [str(value) for value in router_emit]
method_definition.persist = _build_persistence_definition(method)
return method_definition
def _iter_flow_methods(flow_class: type) -> dict[str, Any]:
methods: dict[str, Any] = {}
for attr_name in dir(flow_class):
for attr_name in flow_class.__dict__:
if attr_name.startswith("_"):
continue
try:
@@ -384,6 +401,29 @@ def _iter_flow_methods(flow_class: type) -> dict[str, Any]:
flow_class, attr_value
):
methods[attr_name] = attr_value
continue
inherited = _get_inherited_conversational_method(flow_class, attr_name)
if inherited is not None and callable(attr_value):
methods[attr_name] = _stamp_inherited_conversational_metadata(
attr_value, inherited
)
if _is_conversational_flow(flow_class):
for base in reversed(flow_class.__mro__[1:]):
for attr_name, raw_value in base.__dict__.items():
if attr_name.startswith("_") or attr_name in methods:
continue
if not getattr(raw_value, "__conversational_only__", False):
continue
try:
attr_value = getattr(flow_class, attr_name)
except AttributeError:
continue
if is_flow_method(attr_value) and _should_include_flow_method(
flow_class, attr_value
):
methods[attr_name] = attr_value
# A wrapped method whose name collides with a base Flow model field
# (e.g. ``checkpoint``) is absorbed by Pydantic as a field; the underlying
@@ -427,7 +467,8 @@ def _build_flow_definition_from_class(
description=description,
state=_build_state_definition(flow_class, diagnostics),
config=_build_config_definition(flow_class, diagnostics),
persist=_build_persistence_definition(flow_class, diagnostics, "persist"),
persist=_build_persistence_definition(flow_class),
conversational=_build_conversational_definition(flow_class, diagnostics),
methods=methods,
diagnostics=diagnostics,
)
@@ -442,88 +483,3 @@ def build_flow_definition(
) -> FlowDefinition:
"""Build a FlowDefinition from a Python Flow class."""
return _build_flow_definition_from_class(flow_class, namespace)
def extract_flow_definition(
namespace: dict[str, Any],
) -> tuple[list[str], dict[str, Any], set[str], dict[str, Any]]:
"""Extract the structural flow registries from a Python class namespace."""
start_methods = []
listeners = {}
router_emit = {}
routers = set()
for attr_name, attr_value in namespace.items():
if is_flow_method(attr_value):
method_definition = _get_flow_method_definition(attr_value)
if method_definition is not None:
if method_definition.is_start:
start_methods.append(attr_name)
condition = _definition_trigger_condition(method_definition)
if condition is not None:
listeners[attr_name] = _runtime_listener_condition_from_definition(
condition
)
is_router = method_definition.router or bool(
getattr(attr_value, "__is_router__", False)
)
if is_router:
routers.add(attr_name)
if method_definition.emit:
router_emit[attr_name] = [
str(value) for value in method_definition.emit
]
elif (
hasattr(attr_value, "__router_emit__")
and attr_value.__router_emit__
):
router_emit[attr_name] = attr_value.__router_emit__
else:
router_emit[attr_name] = []
continue
if hasattr(attr_value, "__is_start_method__"):
start_methods.append(attr_name)
if (
hasattr(attr_value, "__trigger_methods__")
and attr_value.__trigger_methods__ is not None
):
methods = attr_value.__trigger_methods__
condition_type = getattr(attr_value, "__condition_type__", OR_CONDITION)
if (
hasattr(attr_value, "__trigger_condition__")
and attr_value.__trigger_condition__ is not None
):
listeners[attr_name] = attr_value.__trigger_condition__
else:
listeners[attr_name] = (condition_type, methods)
if hasattr(attr_value, "__is_router__") and attr_value.__is_router__:
routers.add(attr_name)
if (
hasattr(attr_value, "__router_emit__")
and attr_value.__router_emit__
):
router_emit[attr_name] = attr_value.__router_emit__
else:
router_emit[attr_name] = []
if (
hasattr(attr_value, "__is_start_method__")
and hasattr(attr_value, "__is_router__")
and attr_value.__is_router__
):
routers.add(attr_name)
if (
hasattr(attr_value, "__router_emit__")
and attr_value.__router_emit__
):
router_emit[attr_name] = attr_value.__router_emit__
else:
router_emit[attr_name] = []
return start_methods, listeners, routers, router_emit

View File

@@ -6,15 +6,22 @@ The implementation now lives in three modules, split by concern:
``@router``, ``or_`` / ``and_``) and Python Flow class projection
- ``crewai.flow.flow_definition`` -- the serializable Flow Definition contract
- ``crewai.flow.runtime`` -- the Flow execution engine and state
- ``crewai.experimental.conversational_mixin`` -- experimental conversational
runtime extension composed onto the public ``Flow`` class
Prefer importing from those modules in new code; this module preserves the
historical ``crewai.flow.flow`` import path.
"""
from typing import Any, TypeVar
from pydantic import BaseModel
from crewai.experimental.conversational_mixin import _ConversationalMixin
from crewai.flow.dsl import and_, listen, or_, router, start
from crewai.flow.runtime import (
_INITIAL_STATE_CLASS_MARKER,
Flow,
Flow as RuntimeFlow,
FlowMeta,
FlowState,
LockedDictProxy,
@@ -23,6 +30,13 @@ from crewai.flow.runtime import (
)
T = TypeVar("T", bound=dict[str, Any] | BaseModel)
class Flow(_ConversationalMixin, RuntimeFlow[T]):
"""Public Flow class with experimental conversational extension behavior."""
__all__ = [
"_INITIAL_STATE_CLASS_MARKER",
"Flow",

View File

@@ -15,6 +15,10 @@ current_flow_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"flow_id", default=None
)
current_flow_defer_trace_finalization: contextvars.ContextVar[bool] = (
contextvars.ContextVar("flow_defer_trace_finalization", default=False)
)
current_flow_method_name: contextvars.ContextVar[str] = contextvars.ContextVar(
"flow_method_name", default="unknown"
)

View File

@@ -13,26 +13,45 @@ import json
import logging
from typing import Any, Literal as TypingLiteral
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator
import yaml
from crewai.flow.conversational_definition import (
FlowConversationalDefinition,
FlowConversationalRouterDefinition,
)
logger = logging.getLogger(__name__)
FlowDefinitionCondition = str | dict[str, Any]
__all__ = [
"FlowActionDefinition",
"FlowCodeActionDefinition",
"FlowConfigDefinition",
"FlowConversationalDefinition",
"FlowConversationalRouterDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowStateDefinition",
"FlowToolActionDefinition",
]
def _object_ref(value: Any) -> str:
"""Format a class or instance as the canonical ``module:qualname`` ref."""
target = value if isinstance(value, type) else type(value)
module = getattr(target, "__module__", "")
qualname = getattr(target, "__qualname__", getattr(target, "__name__", ""))
return f"{module}:{qualname}" if module and qualname else repr(value)
class FlowDefinitionDiagnostic(BaseModel):
"""A non-fatal Flow Definition build or validation diagnostic."""
@@ -45,9 +64,10 @@ class FlowDefinitionDiagnostic(BaseModel):
class FlowStateDefinition(BaseModel):
"""Static description of a Flow state contract."""
type: TypingLiteral["dict", "pydantic", "unknown"] = "dict"
type: TypingLiteral["dict", "pydantic", "json_schema", "unknown"] = "dict"
ref: str | None = None
default: Any = None
json_schema: dict[str, Any] | None = None
default: dict[str, Any] | None = None
class FlowConfigDefinition(BaseModel):
@@ -55,22 +75,50 @@ class FlowConfigDefinition(BaseModel):
tracing: bool | None = None
stream: bool = False
memory: Any = None
input_provider: Any = None
memory: dict[str, Any] | None = None
input_provider: str | None = None
suppress_flow_events: bool = False
max_method_calls: int = 100
defer_trace_finalization: bool = False
checkpoint: bool | dict[str, Any] | None = None
class FlowPersistenceDefinition(BaseModel):
"""Static persistence configuration."""
"""Static persistence configuration.
``persistence`` may hold a live backend when the definition is built from
a decorated class — the engine then persists through the exact instance
the user configured; the JSON/YAML projection degrades it to its
serialized config.
"""
enabled: bool = False
verbose: bool = False
persistence: Any = None
@field_serializer("persistence", when_used="json")
def _serialize_persistence(self, value: Any) -> Any:
if value is None or isinstance(value, dict):
return value
if isinstance(value, BaseModel):
try:
return value.model_dump(mode="json")
except Exception:
logger.warning(
"Persistence backend %s is not fully serializable; "
"preserved import reference only.",
_object_ref(value),
)
return {"ref": _object_ref(value)}
class FlowHumanFeedbackDefinition(BaseModel):
"""Static human feedback configuration."""
"""Static human feedback configuration.
``llm`` and ``provider`` may hold live Python objects when the definition
is built from a decorated class; the JSON/YAML projection degrades them to
a serialized config (``llm``) or a ``module:qualname`` ref (``provider``).
"""
message: str
emit: list[str] | None = None
@@ -82,10 +130,58 @@ class FlowHumanFeedbackDefinition(BaseModel):
learn_source: str = "hitl"
learn_strict: bool = False
@field_serializer("llm", when_used="json")
def _serialize_llm(self, value: Any) -> dict[str, Any] | str | None:
if value is None or isinstance(value, (str, dict)):
return value
from crewai.flow.human_feedback import _serialize_llm_for_context
return _serialize_llm_for_context(value)
@field_serializer("provider", when_used="json")
def _serialize_provider(self, value: Any) -> str | None:
if value is None or isinstance(value, str):
return value
return _object_ref(value)
class FlowCodeActionDefinition(BaseModel):
"""A Flow method action that executes importable Python code."""
model_config = ConfigDict(extra="forbid")
call: TypingLiteral["code"] = "code"
ref: str
class FlowToolActionDefinition(BaseModel):
"""A Flow method action that invokes a CrewAI tool."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: TypingLiteral["tool"]
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
class FlowExpressionActionDefinition(BaseModel):
"""A Flow method action that evaluates a CEL expression."""
model_config = ConfigDict(extra="forbid")
call: TypingLiteral["expression"]
expr: str
FlowActionDefinition = (
FlowCodeActionDefinition | FlowToolActionDefinition | FlowExpressionActionDefinition
)
class FlowMethodDefinition(BaseModel):
"""Static definition of one Flow method and its execution roles."""
do: FlowActionDefinition
start: bool | FlowDefinitionCondition | None = None
listen: FlowDefinitionCondition | None = None
router: bool = False
@@ -93,6 +189,16 @@ class FlowMethodDefinition(BaseModel):
human_feedback: FlowHumanFeedbackDefinition | None = None
persist: FlowPersistenceDefinition | None = None
@model_validator(mode="after")
def _canonicalize_human_feedback_routing(self) -> FlowMethodDefinition:
# Canonical shape: a method whose human_feedback declares emit
# outcomes routes like a router, regardless of how the definition
# was authored.
if self.human_feedback is not None and self.human_feedback.emit:
self.router = True
self.emit = None
return self
@property
def is_start(self) -> bool:
"""Whether this method is a start method.
@@ -109,12 +215,15 @@ class FlowDefinition(BaseModel):
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
schema_: str = Field(default="crewai.flow/v1", alias="schema")
schema_: TypingLiteral["crewai.flow/v1"] = Field(
default="crewai.flow/v1", alias="schema"
)
name: str
description: str | None = None
state: FlowStateDefinition | None = None
config: FlowConfigDefinition = Field(default_factory=FlowConfigDefinition)
persist: FlowPersistenceDefinition | None = None
conversational: FlowConversationalDefinition | None = None
methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict)
diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list)

View File

@@ -16,7 +16,6 @@ P = ParamSpec("P")
R = TypeVar("R")
FlowConditionType: TypeAlias = Literal["OR", "AND"]
SimpleFlowCondition: TypeAlias = tuple[FlowConditionType, list[FlowMethodName]]
__all__ = [
"FlowCondition",
@@ -25,7 +24,6 @@ __all__ = [
"FlowMethod",
"ListenMethod",
"RouterMethod",
"SimpleFlowCondition",
"StartMethod",
]
@@ -38,15 +36,13 @@ class FlowCondition(TypedDict, total=False):
Attributes:
type: The type of the condition.
conditions: A sequence of route labels, method names, or nested conditions.
methods: A legacy sequence of route labels or method names.
"""
type: Required[FlowConditionType]
conditions: Sequence[str | FlowMethodName | FlowCondition]
methods: Sequence[str | FlowMethodName]
conditions: Sequence[str | FlowCondition]
FlowConditions: TypeAlias = Sequence[str | FlowMethodName | FlowCondition]
FlowConditions: TypeAlias = Sequence[str | FlowCondition]
class FlowMethod(Generic[P, R]):
@@ -83,13 +79,10 @@ class FlowMethod(Generic[P, R]):
# Preserve flow-related attributes from wrapped method (e.g., from @human_feedback)
for attr in [
"__is_router__",
"__router_emit__",
"__human_feedback_config__",
"__conversational_only__", # gates registration on Flow.conversational
"__flow_persistence_config__",
"__flow_method_definition__",
"_human_feedback_llm", # Live LLM object for HITL resume
]:
if hasattr(meth, attr):
setattr(self, attr, getattr(meth, attr))
@@ -158,25 +151,10 @@ class FlowMethod(Generic[P, R]):
class StartMethod(FlowMethod[P, R]):
"""Wrapper for methods marked as flow start points."""
__is_start_method__: bool = True
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
class ListenMethod(FlowMethod[P, R]):
"""Wrapper for methods marked as flow listeners."""
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
class RouterMethod(FlowMethod[P, R]):
"""Wrapper for methods marked as flow routers."""
__is_router__: bool = True
__trigger_methods__: list[FlowMethodName] | None = None
__condition_type__: FlowConditionType | None = None
__trigger_condition__: FlowCondition | None = None
__router_emit__: list[str] | None = None

View File

@@ -1,8 +1,11 @@
"""Human feedback decorator for Flow methods.
"""Human feedback support for Flow methods.
This module provides the @human_feedback decorator that enables human-in-the-loop
workflows within CrewAI Flows. It allows collecting human feedback on method outputs
and optionally routing to different listeners based on the feedback.
This module backs the @human_feedback decorator that enables human-in-the-loop
workflows within CrewAI Flows. The decorator is a pure metadata stamper: it
records a :class:`HumanFeedbackConfig` on the method, the Flow definition
builder lifts it into ``FlowHumanFeedbackDefinition``, and the Flow engine
collects feedback after each decorated method completes, driven by the flow's
definition.
Supports both synchronous (blocking) and asynchronous (non-blocking) feedback
collection through the provider parameter.
@@ -55,22 +58,18 @@ Example (asynchronous with custom provider):
from __future__ import annotations
import asyncio
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from functools import wraps
import logging
from typing import TYPE_CHECKING, Any, TypeVar
from pydantic import BaseModel, Field
from crewai.flow.flow_wrappers import FlowMethod
if TYPE_CHECKING:
from crewai.flow.async_feedback.types import HumanFeedbackProvider
from crewai.flow.flow import Flow
from crewai.flow.runtime import Flow
from crewai.llms.base_llm import BaseLLM
@@ -160,8 +159,8 @@ class HumanFeedbackResult:
class HumanFeedbackConfig:
"""Configuration for the @human_feedback decorator.
Stores the parameters passed to the decorator for later use during
method execution and for introspection by visualization tools.
Stores the parameters passed to the decorator for later use by the
Flow definition builder and for introspection by visualization tools.
Attributes:
message: The message shown to the human when requesting feedback.
@@ -183,23 +182,6 @@ class HumanFeedbackConfig:
learn_strict: bool = False
class HumanFeedbackMethod(FlowMethod[Any, Any]):
"""Wrapper for methods decorated with @human_feedback.
This wrapper extends FlowMethod to add human feedback specific attributes
that are used by FlowMeta for routing and by visualization tools.
Attributes:
__is_router__: True when emit is specified, enabling router behavior.
__router_emit__: List of possible outcomes when acting as a router.
__human_feedback_config__: The HumanFeedbackConfig for this method.
"""
__is_router__: bool = False
__router_emit__: list[str] | None = None
__human_feedback_config__: HumanFeedbackConfig | None = None
class PreReviewResult(BaseModel):
"""Structured output from the HITL pre-review LLM call."""
@@ -221,17 +203,11 @@ class DistilledLessons(BaseModel):
)
def _build_human_feedback_runtime_decorator(
message: str,
emit: Sequence[str] | None = None,
llm: str | BaseLLM | None = "gpt-4o-mini",
default_outcome: str | None = None,
metadata: dict[str, Any] | None = None,
provider: HumanFeedbackProvider | None = None,
learn: bool = False,
learn_source: str = "hitl",
learn_strict: bool = False,
) -> Callable[[F], F]:
def _validate_human_feedback_options(
emit: Sequence[str] | None,
llm: Any,
default_outcome: str | None,
) -> None:
if emit is not None:
if not llm:
raise ValueError(
@@ -248,295 +224,139 @@ def _build_human_feedback_runtime_decorator(
elif default_outcome is not None:
raise ValueError("default_outcome requires emit to be specified.")
def decorator(func: F) -> F:
def _get_hitl_prompt(key: str) -> str:
from crewai.utilities.i18n import I18N_DEFAULT
return I18N_DEFAULT.slice(key)
def _get_hitl_prompt(key: str) -> str:
from crewai.utilities.i18n import I18N_DEFAULT
def _resolve_llm_instance() -> Any:
if llm is None:
from crewai.llm import LLM
return I18N_DEFAULT.slice(key)
return LLM(model="gpt-4o-mini")
if isinstance(llm, str):
from crewai.llm import LLM
return LLM(model=llm)
return llm # already a BaseLLM instance
def _resolve_llm_instance(llm: Any) -> Any:
from crewai.llm import LLM
def _pre_review_with_lessons(
flow_instance: Flow[Any], method_output: Any
) -> Any:
try:
mem = flow_instance.memory
if mem is None:
return method_output
query = f"human feedback lessons for {func.__name__}: {method_output!s}"
matches = mem.recall(query, source=learn_source)
if not matches:
return method_output
if llm is None:
return LLM(model="gpt-4o-mini")
if isinstance(llm, str):
return LLM(model=llm)
if isinstance(llm, dict):
deserialized = _deserialize_llm_from_context(llm)
return deserialized if deserialized is not None else LLM(model="gpt-4o-mini")
return llm # already a BaseLLM instance
lessons = "\n".join(f"- {m.record.content}" for m in matches)
llm_inst = _resolve_llm_instance()
prompt = _get_hitl_prompt("hitl_pre_review_user").format(
output=str(method_output),
lessons=lessons,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_pre_review_system"),
},
{"role": "user", "content": prompt},
]
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=PreReviewResult)
if isinstance(response, PreReviewResult):
return response.improved_output
return PreReviewResult.model_validate(response).improved_output
reviewed = llm_inst.call(messages)
return reviewed if isinstance(reviewed, str) else str(reviewed)
except Exception:
if learn_strict:
logger.warning(
"HITL pre-review failed for %s; re-raising (learn_strict=True)",
func.__name__,
exc_info=True,
)
raise
logger.warning(
"HITL pre-review failed for %s; falling back to raw output",
func.__name__,
exc_info=True,
)
return method_output
def _distill_and_store_lessons(
flow_instance: Flow[Any], method_output: Any, raw_feedback: str
) -> None:
try:
mem = flow_instance.memory
if mem is None:
return
llm_inst = _resolve_llm_instance()
prompt = _get_hitl_prompt("hitl_distill_user").format(
method_name=func.__name__,
output=str(method_output),
feedback=raw_feedback,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_distill_system"),
},
{"role": "user", "content": prompt},
]
def _pre_review_with_lessons(
flow_instance: Flow[Any],
method_name: str,
method_output: Any,
*,
llm: Any,
learn_source: str,
learn_strict: bool,
) -> Any:
try:
mem = flow_instance.memory
if mem is None:
return method_output
query = f"human feedback lessons for {method_name}: {method_output!s}"
matches = mem.recall(query, source=learn_source)
if not matches:
return method_output
lessons: list[str] = []
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=DistilledLessons)
if isinstance(response, DistilledLessons):
lessons = response.lessons
else:
lessons = DistilledLessons.model_validate(response).lessons
else:
response = llm_inst.call(messages)
if isinstance(response, str):
lessons = [
line.strip("- ").strip()
for line in response.strip().split("\n")
if line.strip() and line.strip() != "NONE"
]
if lessons:
mem.remember_many(lessons, source=learn_source) # type: ignore[union-attr]
except Exception:
if learn_strict:
logger.warning(
"HITL lesson distillation failed for %s; re-raising (learn_strict=True)",
func.__name__,
exc_info=True,
)
raise
logger.warning(
"HITL lesson distillation failed for %s; no lessons stored",
func.__name__,
exc_info=True,
)
def _build_feedback_context(
flow_instance: Flow[Any], method_output: Any
) -> tuple[Any, Any]:
from crewai.flow.async_feedback.types import PendingFeedbackContext
context = PendingFeedbackContext(
flow_id=flow_instance.flow_id or "unknown",
flow_class=f"{flow_instance.__class__.__module__}.{flow_instance.__class__.__name__}",
method_name=func.__name__,
method_output=method_output,
message=message,
emit=list(emit) if emit else None,
default_outcome=default_outcome,
metadata=metadata or {},
llm=llm if isinstance(llm, str) else _serialize_llm_for_context(llm),
lessons = "\n".join(f"- {m.record.content}" for m in matches)
llm_inst = _resolve_llm_instance(llm)
prompt = _get_hitl_prompt("hitl_pre_review_user").format(
output=str(method_output),
lessons=lessons,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_pre_review_system"),
},
{"role": "user", "content": prompt},
]
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=PreReviewResult)
if isinstance(response, PreReviewResult):
return response.improved_output
return PreReviewResult.model_validate(response).improved_output
reviewed = llm_inst.call(messages)
return reviewed if isinstance(reviewed, str) else str(reviewed)
except Exception:
if learn_strict:
logger.warning(
"HITL pre-review failed for %s; re-raising (learn_strict=True)",
method_name,
exc_info=True,
)
raise
logger.warning(
"HITL pre-review failed for %s; falling back to raw output",
method_name,
exc_info=True,
)
return method_output
effective_provider = provider
if effective_provider is None:
from crewai.flow.flow_config import flow_config
effective_provider = flow_config.hitl_provider
def _distill_and_store_lessons(
flow_instance: Flow[Any],
method_name: str,
method_output: Any,
raw_feedback: str,
*,
llm: Any,
learn_source: str,
learn_strict: bool,
) -> None:
try:
mem = flow_instance.memory
if mem is None:
return
llm_inst = _resolve_llm_instance(llm)
prompt = _get_hitl_prompt("hitl_distill_user").format(
method_name=method_name,
output=str(method_output),
feedback=raw_feedback,
)
messages = [
{
"role": "system",
"content": _get_hitl_prompt("hitl_distill_system"),
},
{"role": "user", "content": prompt},
]
return context, effective_provider
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
if effective_provider is not None:
feedback_result = effective_provider.request_feedback(
context, flow_instance
)
if asyncio.iscoroutine(feedback_result):
raise TypeError(
f"Provider {type(effective_provider).__name__}.request_feedback() "
"returned a coroutine in a sync flow method. Use an async flow "
"method or a synchronous provider."
)
return str(feedback_result)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
metadata=metadata,
emit=emit,
)
async def _request_feedback_async(
flow_instance: Flow[Any], method_output: Any
) -> str:
context, effective_provider = _build_feedback_context(
flow_instance, method_output
)
if effective_provider is not None:
feedback_result = effective_provider.request_feedback(
context, flow_instance
)
if asyncio.iscoroutine(feedback_result):
return str(await feedback_result)
return str(feedback_result)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
metadata=metadata,
emit=emit,
)
def _process_feedback(
flow_instance: Flow[Any],
method_output: Any,
raw_feedback: str,
) -> HumanFeedbackResult | str:
collapsed_outcome: str | None = None
if not raw_feedback.strip():
if default_outcome:
collapsed_outcome = default_outcome
elif emit:
collapsed_outcome = emit[0]
elif emit:
if llm is not None:
collapsed_outcome = flow_instance._collapse_to_outcome(
feedback=raw_feedback,
outcomes=emit,
llm=llm,
)
else:
collapsed_outcome = emit[0]
result = HumanFeedbackResult(
output=method_output,
feedback=raw_feedback,
outcome=collapsed_outcome,
timestamp=datetime.now(),
method_name=func.__name__,
metadata=metadata or {},
)
flow_instance.human_feedback_history.append(result)
flow_instance.last_human_feedback = result
if emit:
if collapsed_outcome is None:
collapsed_outcome = default_outcome or emit[0]
result.outcome = collapsed_outcome
return collapsed_outcome
return result
if asyncio.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
method_output = await func(self, *args, **kwargs)
if learn and getattr(self, "memory", None) is not None:
method_output = _pre_review_with_lessons(self, method_output)
raw_feedback = await _request_feedback_async(self, method_output)
result = _process_feedback(self, method_output, raw_feedback)
if (
learn
and getattr(self, "memory", None) is not None
and raw_feedback.strip()
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set:
# result is the collapsed outcome string for routing, but we preserve the
# actual method output as the flow's final result. Uses per-method dict for
# concurrency safety and to handle None returns.
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper: Any = async_wrapper
lessons: list[str] = []
if getattr(llm_inst, "supports_function_calling", lambda: False)():
response = llm_inst.call(messages, response_model=DistilledLessons)
if isinstance(response, DistilledLessons):
lessons = response.lessons
else:
lessons = DistilledLessons.model_validate(response).lessons
else:
response = llm_inst.call(messages)
if isinstance(response, str):
lessons = [
line.strip("- ").strip()
for line in response.strip().split("\n")
if line.strip() and line.strip() != "NONE"
]
@wraps(func)
def sync_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
method_output = func(self, *args, **kwargs)
if learn and getattr(self, "memory", None) is not None:
method_output = _pre_review_with_lessons(self, method_output)
raw_feedback = _request_feedback(self, method_output)
result = _process_feedback(self, method_output, raw_feedback)
if (
learn
and getattr(self, "memory", None) is not None
and raw_feedback.strip()
):
_distill_and_store_lessons(self, method_output, raw_feedback)
# Stash the real method output for final flow result when emit is set:
# result is the collapsed outcome string for routing, but we preserve the
# actual method output as the flow's final result. Uses per-method dict for
# concurrency safety and to handle None returns.
if emit:
self._human_feedback_method_outputs[func.__name__] = method_output
return result
wrapper = sync_wrapper
return wrapper # type: ignore[no-any-return]
return decorator
if lessons:
mem.remember_many(lessons, source=learn_source) # type: ignore[union-attr]
except Exception:
if learn_strict:
logger.warning(
"HITL lesson distillation failed for %s; re-raising (learn_strict=True)",
method_name,
exc_info=True,
)
raise
logger.warning(
"HITL lesson distillation failed for %s; no lessons stored",
method_name,
exc_info=True,
)
def human_feedback(

View File

@@ -24,22 +24,20 @@ Example:
from __future__ import annotations
import asyncio
from collections.abc import Callable
import functools
import logging
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
from typing import TYPE_CHECKING, Any, Final, TypeVar
from crewai_core.printer import PRINTER
from pydantic import BaseModel
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
from crewai.flow.persistence.factory import default_flow_persistence
if TYPE_CHECKING:
from crewai.flow.flow import Flow
from crewai.flow.runtime import Flow
logger = logging.getLogger(__name__)
@@ -66,20 +64,6 @@ def _stamp_persistence_metadata(
)
_PRESERVED_FLOW_ATTRS: Final[tuple[str, ...]] = (
"__is_start_method__",
"__trigger_methods__",
"__condition_type__",
"__trigger_condition__",
"__is_router__",
"__router_emit__",
"__human_feedback_config__",
"__flow_persistence_config__",
"__flow_method_definition__",
"_human_feedback_llm",
)
class PersistenceDecorator:
"""Class to handle flow state persistence with consistent logging."""
@@ -170,9 +154,15 @@ def persist(
states. When applied at the method level, it persists only that method's
state.
The decorator is a pure metadata stamper: it records the persistence
configuration on the class or method, and the Flow engine saves state
after each persisted method completes, driven by the flow's definition.
Args:
persistence: Optional FlowPersistence implementation to use.
If not provided, uses SQLiteFlowPersistence.
If not provided, uses ``default_flow_persistence()`` (the
registered factory when present, else the built-in SQLite
fallback).
verbose: Whether to log persistence operations. Defaults to False.
Returns:
@@ -191,127 +181,11 @@ def persist(
"""
def decorator(target: type | Callable[..., T]) -> type | Callable[..., T]:
actual_persistence = persistence or SQLiteFlowPersistence()
actual_persistence = (
persistence if persistence is not None else default_flow_persistence()
)
if isinstance(target, type):
_stamp_persistence_metadata(target, actual_persistence, verbose)
original_init = target.__init__ # type: ignore[misc]
@functools.wraps(original_init)
def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
if "persistence" not in kwargs:
kwargs["persistence"] = actual_persistence
original_init(self, *args, **kwargs)
target.__init__ = new_init # type: ignore[misc]
# Preserve original methods' decorators
original_methods = {
name: method
for name, method in target.__dict__.items()
if callable(method)
and (
hasattr(method, "__is_start_method__")
or hasattr(method, "__trigger_methods__")
or hasattr(method, "__condition_type__")
or hasattr(method, "__is_flow_method__")
or hasattr(method, "__is_router__")
)
}
for name, method in original_methods.items():
if asyncio.iscoroutinefunction(method):
# Closure captures the current name and method
def create_async_wrapper(
method_name: str, original_method: Callable[..., Any]
) -> Callable[..., Any]:
@functools.wraps(original_method)
async def method_wrapper(
self: Any, *args: Any, **kwargs: Any
) -> Any:
result = await original_method(self, *args, **kwargs)
PersistenceDecorator.persist_state(
self, method_name, actual_persistence, verbose
)
return result
return method_wrapper
wrapped = create_async_wrapper(name, method)
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(wrapped, attr, getattr(method, attr))
wrapped.__is_flow_method__ = True # type: ignore[attr-defined]
setattr(target, name, wrapped)
else:
def create_sync_wrapper(
method_name: str, original_method: Callable[..., Any]
) -> Callable[..., Any]:
@functools.wraps(original_method)
def method_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
result = original_method(self, *args, **kwargs)
PersistenceDecorator.persist_state(
self, method_name, actual_persistence, verbose
)
return result
return method_wrapper
wrapped = create_sync_wrapper(name, method)
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(wrapped, attr, getattr(method, attr))
wrapped.__is_flow_method__ = True # type: ignore[attr-defined]
setattr(target, name, wrapped)
return target
method = target
method.__is_flow_method__ = True # type: ignore[attr-defined]
_stamp_persistence_metadata(method, actual_persistence, verbose)
if asyncio.iscoroutinefunction(method):
@functools.wraps(method)
async def method_async_wrapper(
flow_instance: Any, *args: Any, **kwargs: Any
) -> T:
method_coro = method(flow_instance, *args, **kwargs)
if asyncio.iscoroutine(method_coro):
result = await method_coro
else:
result = method_coro
PersistenceDecorator.persist_state(
flow_instance, method.__name__, actual_persistence, verbose
)
return cast(T, result)
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(method_async_wrapper, attr, getattr(method, attr))
method_async_wrapper.__is_flow_method__ = True # type: ignore[attr-defined]
_stamp_persistence_metadata(
method_async_wrapper, actual_persistence, verbose
)
return cast(Callable[..., T], method_async_wrapper)
@functools.wraps(method)
def method_sync_wrapper(flow_instance: Any, *args: Any, **kwargs: Any) -> T:
result = method(flow_instance, *args, **kwargs)
PersistenceDecorator.persist_state(
flow_instance, method.__name__, actual_persistence, verbose
)
return result
for attr in _PRESERVED_FLOW_ATTRS:
if hasattr(method, attr):
setattr(method_sync_wrapper, attr, getattr(method, attr))
method_sync_wrapper.__is_flow_method__ = True # type: ignore[attr-defined]
_stamp_persistence_metadata(method_sync_wrapper, actual_persistence, verbose)
return cast(Callable[..., T], method_sync_wrapper)
_stamp_persistence_metadata(target, actual_persistence, verbose)
return target
return decorator

View File

@@ -0,0 +1,60 @@
"""Pluggable default persistence backend for flows.
By default, ``@persist`` and the flow runtime persist state with
:class:`~crewai.flow.persistence.sqlite.SQLiteFlowPersistence` when no explicit
``persistence=`` is given. Registering a factory via
:func:`set_flow_persistence_factory` lets an application back flow state with a
custom :class:`~crewai.flow.persistence.base.FlowPersistence` -- a database, a
remote service, an in-memory fake for tests -- without passing a
``persistence=`` instance at every ``@persist`` / kickoff site.
This mirrors :func:`crewai_core.lock_store.set_lock_backend`: a one-time,
process-wide setter intended for application startup. Pass ``None`` to restore
the built-in SQLite default. Call :func:`default_flow_persistence` to build the
default backend (the registered factory if any, else SQLite).
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from crewai.flow.persistence.base import FlowPersistence
FlowPersistenceFactory = Callable[[], "FlowPersistence"]
_factory: FlowPersistenceFactory | None = None
def set_flow_persistence_factory(factory: FlowPersistenceFactory | None) -> None:
"""Replace the process-wide default flow persistence factory.
Intended for one-time setup at startup. Pass ``None`` to restore the
built-in ``SQLiteFlowPersistence``. Only affects flows that fall back to
the default; an explicit ``persistence=`` instance always wins.
The default is resolved at each fall-back site (``@persist`` and the
runtime's pause/resume paths), so the factory may be called more than once
for a single flow. Return instances backed by shared durable state (or a
singleton) so state saved on one call is visible to the next -- the
built-in SQLite default satisfies this by sharing one on-disk file.
"""
global _factory
_factory = factory
def default_flow_persistence() -> FlowPersistence:
"""Build the default flow persistence backend.
Returns the result of the registered factory if one is set, otherwise a
built-in :class:`~crewai.flow.persistence.sqlite.SQLiteFlowPersistence`.
"""
factory = _factory
if factory is not None:
return factory()
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
return SQLiteFlowPersistence()

View File

@@ -0,0 +1,144 @@
"""Runtime expression support for FlowDefinition CEL expressions."""
from __future__ import annotations
import copy
import dataclasses
from itertools import pairwise
import json
import re
from typing import TYPE_CHECKING, Any, cast
from pydantic import BaseModel
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
_EXPRESSION_PATTERN = re.compile(r"\$\{([^{}]*)\}")
__all__ = ["FlowExpressionError", "evaluate_expression", "render_with_block"]
class FlowExpressionError(ValueError):
"""A FlowDefinition expression failed to parse or evaluate."""
def render_with_block(flow: Flow[Any], value: Any) -> Any:
"""Render CEL expressions inside a FlowDefinition ``with:`` payload."""
context = _expression_context(flow)
return _render_value(value, context)
def evaluate_expression(flow: Flow[Any], expression: str) -> Any:
"""Evaluate a FlowDefinition CEL expression against runtime context."""
expression = expression.strip()
if not expression:
raise FlowExpressionError("empty CEL expression")
return _eval_cel(expression, _expression_context(flow))
def _expression_context(flow: Flow[Any]) -> dict[str, Any]:
return {
"state": flow._copy_and_serialize_state(),
"outputs": _outputs_by_name(flow._method_outputs),
}
def _outputs_by_name(method_outputs: list[Any]) -> dict[str, Any]:
outputs: dict[str, Any] = {}
for entry in method_outputs:
method = ""
output = entry
if isinstance(entry, dict) and "output" in entry:
method = str(entry.get("method", ""))
output = entry["output"]
output = copy.deepcopy(output)
if isinstance(output, BaseModel):
output = output.model_dump(mode="json")
elif dataclasses.is_dataclass(output) and not isinstance(output, type):
output = dataclasses.asdict(output)
outputs[method] = output
return outputs
def _render_value(value: Any, context: dict[str, Any]) -> Any:
if isinstance(value, str):
return _render_string(value, context)
if isinstance(value, dict):
return {key: _render_value(item, context) for key, item in value.items()}
if isinstance(value, list):
return [_render_value(item, context) for item in value]
return value
def _render_string(value: str, context: dict[str, Any]) -> Any:
matches = list(_EXPRESSION_PATTERN.finditer(value))
if not matches:
_raise_for_invalid_interpolation(value)
return value
_raise_for_literal_braces(value[: matches[0].start()])
for previous, current in pairwise(matches):
_raise_for_literal_braces(value[previous.end() : current.start()])
_raise_for_literal_braces(value[matches[-1].end() :])
if len(matches) == 1 and matches[0].span() == (0, len(value)):
expression = matches[0].group(1).strip()
if not expression:
raise FlowExpressionError("empty CEL expression in with block")
return _eval_cel(expression, context)
rendered: list[str] = []
position = 0
for match in matches:
start, end = match.span()
literal = value[position:start]
rendered.append(literal)
expression = match.group(1).strip()
if not expression:
raise FlowExpressionError("empty CEL expression in with block")
result = _eval_cel(expression, context)
rendered.append(result if isinstance(result, str) else json.dumps(result))
position = end
literal = value[position:]
rendered.append(literal)
return "".join(rendered)
def _raise_for_invalid_interpolation(value: str) -> None:
if "${" not in value:
return
raise FlowExpressionError(
"invalid CEL interpolation in with block: expressions must be enclosed "
"as ${...} and cannot contain braces"
)
def _raise_for_literal_braces(value: str) -> None:
if "{" not in value and "}" not in value:
return
raise FlowExpressionError(
"invalid CEL interpolation in with block: expressions must be enclosed "
"as ${...} and cannot contain braces"
)
def _eval_cel(expression: str, context: dict[str, Any]) -> Any:
try:
from celpy import Environment
from celpy.adapter import CELJSONEncoder, json_to_cel
from celpy.evaluation import Context
environment = Environment()
program = environment.program(environment.compile(expression))
result = program.evaluate(cast(Context, json_to_cel(context)))
return json.loads(json.dumps(result, cls=CELJSONEncoder))
except Exception as e:
raise FlowExpressionError(
f"failed to evaluate CEL expression {expression!r}: {e}"
) from e

View File

@@ -0,0 +1,116 @@
"""Resolution of FlowDefinition refs (``module:qualname``) into live objects.
Every ref-shaped value in a definition — ``do`` actions, ``state.ref``,
``config.input_provider``, ``human_feedback.provider`` — resolves through
:func:`resolve_ref`. Failures are loud and name the field and the ref.
"""
from __future__ import annotations
from collections.abc import Callable
import importlib
import inspect
from operator import attrgetter
from typing import TYPE_CHECKING, Any, cast
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowCodeActionDefinition,
FlowExpressionActionDefinition,
FlowToolActionDefinition,
)
from crewai.flow.runtime._expressions import evaluate_expression, render_with_block
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
class InvalidRefError(ValueError):
"""A definition ref that cannot be resolved to a live object."""
def resolve_ref(ref: str, *, field: str) -> Any:
"""Import the object a definition's `module:qualname` ref points to."""
module_name, _, qualname = ref.partition(":")
if "<" in ref or not module_name or not qualname:
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected 'module:qualname'"
)
try:
return attrgetter(qualname)(importlib.import_module(module_name))
except (ImportError, AttributeError) as e:
raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e
def resolve_instance_ref(ref: str, *, field: str) -> Any:
"""Resolve a ref, auto-instantiating a no-arg class into an instance."""
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
return target
try:
return target()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate {field} ref {ref!r} without arguments: {e}"
) from e
def _resolve_code_action(
flow: Flow[Any], action: FlowCodeActionDefinition
) -> Callable[..., Any]:
ref = action.ref
target = resolve_ref(ref, field="do")
if not callable(target):
raise InvalidRefError(f"invalid do ref {ref!r}; object is not callable")
handler = cast(Callable[..., Any], target)
if getattr(handler, "__self__", None) is None:
handler = handler.__get__(flow, type(flow))
return handler
def _resolve_tool_action(
flow: Flow[Any], action: FlowToolActionDefinition
) -> Callable[..., Any]:
target = resolve_ref(action.ref, field="do")
from crewai.tools import BaseTool
if not (inspect.isclass(target) and issubclass(target, BaseTool)):
raise InvalidRefError(
f"invalid tool ref {action.ref!r}; expected a BaseTool class"
)
try:
tool_cls = cast(Callable[[], BaseTool], target)
tool = tool_cls()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate tool ref {action.ref!r} without arguments: {e}"
) from e
tool_kwargs = action.with_ or {}
def run_tool(*_args: Any, **_kwargs: Any) -> Any:
return tool.run(**render_with_block(flow, tool_kwargs))
return run_tool
def _resolve_expression_action(
flow: Flow[Any], action: FlowExpressionActionDefinition
) -> Callable[..., Any]:
def run_expression(*_args: Any, **_kwargs: Any) -> Any:
return evaluate_expression(flow, action.expr)
return run_expression
def resolve_action(flow: Flow[Any], action: FlowActionDefinition) -> Callable[..., Any]:
"""Turn one `do:` action into the callable the flow runs for that node."""
if action.call == "code":
return _resolve_code_action(flow, action)
if action.call == "tool":
return _resolve_tool_action(flow, action)
if action.call == "expression":
return _resolve_expression_action(flow, action)
raise ValueError(f"unknown call type {action.call!r}")

View File

@@ -5,15 +5,7 @@ the Flow system.
"""
from datetime import datetime
from typing import (
Annotated,
Any,
NewType,
ParamSpec,
Protocol,
TypeVar,
TypedDict,
)
from typing import Annotated, Any, NewType, ParamSpec, Protocol, TypeVar, TypedDict
from typing_extensions import NotRequired, Required
@@ -24,7 +16,7 @@ R = TypeVar("R", covariant=True)
FlowMethodName = NewType("FlowMethodName", str)
PendingListenerKey = NewType(
"PendingListenerKey",
Annotated[str, "nested flow conditions use 'listener_name:object_id'"],
Annotated[str, "listener method name, or 'start:<method>' for conditional starts"],
)

View File

@@ -13,6 +13,7 @@ from crewai.knowledge.source.string_knowledge_source import StringKnowledgeSourc
from crewai.knowledge.source.text_file_knowledge_source import (
TextFileKnowledgeSource,
)
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
from crewai.rag.embeddings.types import EmbedderConfig
@@ -89,7 +90,7 @@ class Knowledge(BaseModel):
Knowledge is a collection of sources and setup for the vector store to save and query relevant context.
Args:
sources: list[BaseKnowledgeSource] = Field(default_factory=list)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
embedder: EmbedderConfig | None = None
"""
@@ -98,7 +99,7 @@ class Knowledge(BaseModel):
BeforeValidator(_resolve_knowledge_sources),
] = Field(default_factory=list)
model_config = ConfigDict(arbitrary_types_allowed=True)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
embedder: Annotated[
EmbedderConfig | None,
PlainSerializer(
@@ -112,15 +113,22 @@ class Knowledge(BaseModel):
collection_name: str,
sources: list[BaseKnowledgeSource],
embedder: EmbedderConfig | None = None,
storage: KnowledgeStorage | None = None,
storage: BaseKnowledgeStorage | None = None,
**data: object,
) -> None:
super().__init__(**data)
if storage:
if storage is not None:
self.storage = storage
else:
self.storage = KnowledgeStorage(
embedder=embedder, collection_name=collection_name
from crewai.knowledge.storage.factory import resolve_knowledge_storage
custom = resolve_knowledge_storage(embedder, collection_name)
self.storage = (
custom
if custom is not None
else KnowledgeStorage(
embedder=embedder, collection_name=collection_name
)
)
self.sources = sources
@@ -152,10 +160,9 @@ class Knowledge(BaseModel):
raise e
def reset(self) -> None:
if self.storage:
self.storage.reset()
else:
if self.storage is None:
raise ValueError("Storage is not initialized.")
self.storage.reset()
async def aquery(
self, query: list[str], results_limit: int = 5, score_threshold: float = 0.6
@@ -193,7 +200,6 @@ class Knowledge(BaseModel):
async def areset(self) -> None:
"""Reset the knowledge base asynchronously."""
if self.storage:
await self.storage.areset()
else:
if self.storage is None:
raise ValueError("Storage is not initialized.")
await self.storage.areset()

View File

@@ -5,7 +5,7 @@ from typing import Any
from pydantic import Field, field_validator
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.utilities.constants import KNOWLEDGE_DIRECTORY
from crewai.utilities.logger import Logger
@@ -22,7 +22,7 @@ class BaseFileKnowledgeSource(BaseKnowledgeSource, ABC):
default_factory=list, description="The path to the file"
)
content: dict[Path, str] = Field(init=False, default_factory=dict)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
safe_file_paths: list[Path] = Field(default_factory=list)
@field_validator("file_path", "file_paths", mode="before")
@@ -70,14 +70,14 @@ class BaseFileKnowledgeSource(BaseKnowledgeSource, ABC):
def _save_documents(self) -> None:
"""Save the documents to the storage."""
if self.storage:
if self.storage is not None:
self.storage.save(self.chunks)
else:
raise ValueError("No storage found to save documents.")
async def _asave_documents(self) -> None:
"""Save the documents to the storage asynchronously."""
if self.storage:
if self.storage is not None:
await self.storage.asave(self.chunks)
else:
raise ValueError("No storage found to save documents.")

View File

@@ -4,9 +4,15 @@ from typing import Any
import numpy as np
from pydantic import BaseModel, ConfigDict, Field
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.knowledge.storage.knowledge_storage import KnowledgeStorage
# ``KnowledgeStorage`` is re-exported for backwards compatibility; the ``storage``
# field below is typed to the base interface so any backend plugs in.
__all__ = ["BaseKnowledgeSource", "KnowledgeStorage"]
class BaseKnowledgeSource(BaseModel, ABC):
"""Abstract base class for knowledge sources."""
@@ -18,7 +24,7 @@ class BaseKnowledgeSource(BaseModel, ABC):
)
model_config = ConfigDict(arbitrary_types_allowed=True)
storage: KnowledgeStorage | None = Field(default=None)
storage: BaseKnowledgeStorage | None = Field(default=None)
metadata: dict[str, Any] = Field(default_factory=dict) # Currently unused
collection_name: str | None = Field(default=None)
@@ -49,7 +55,7 @@ class BaseKnowledgeSource(BaseModel, ABC):
Raises:
ValueError: If no storage is configured.
"""
if self.storage:
if self.storage is not None:
self.storage.save(self.chunks)
else:
raise ValueError("No storage found to save documents.")
@@ -66,7 +72,7 @@ class BaseKnowledgeSource(BaseModel, ABC):
Raises:
ValueError: If no storage is configured.
"""
if self.storage:
if self.storage is not None:
await self.storage.asave(self.chunks)
else:
raise ValueError("No storage found to save documents.")

View File

@@ -0,0 +1,56 @@
"""Pluggable default storage backend for knowledge collections.
By default, :class:`~crewai.knowledge.knowledge.Knowledge` builds a
:class:`~crewai.knowledge.storage.knowledge_storage.KnowledgeStorage` when no
explicit ``storage=`` is given. Registering a factory via
:func:`set_knowledge_storage_factory` lets an application back knowledge with a
custom :class:`~crewai.knowledge.storage.base_knowledge_storage.BaseKnowledgeStorage`
without subclassing ``Knowledge`` or passing a ``storage=`` instance at every
call site.
This mirrors :func:`crewai_core.lock_store.set_lock_backend`: a one-time,
process-wide setter intended for application startup. Pass ``None`` to restore
the built-in default.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.rag.embeddings.types import EmbedderConfig
# Receives the same inputs as the built-in default -- the embedder config and
# collection name -- and returns a storage backend, or ``None`` to defer to the
# built-in ``KnowledgeStorage``.
KnowledgeStorageFactory = Callable[
["EmbedderConfig | None", "str | None"], "BaseKnowledgeStorage | None"
]
_factory: KnowledgeStorageFactory | None = None
def set_knowledge_storage_factory(factory: KnowledgeStorageFactory | None) -> None:
"""Replace the process-wide default knowledge storage factory.
Intended for one-time setup at startup. Pass ``None`` to restore the
built-in ``KnowledgeStorage``. Only affects ``Knowledge`` instances
constructed afterwards; an explicit ``storage=`` instance always wins.
"""
global _factory
_factory = factory
def resolve_knowledge_storage(
embedder: EmbedderConfig | None, collection_name: str | None
) -> BaseKnowledgeStorage | None:
"""Return the registered factory's backend, or ``None`` for the built-in.
``None`` means no factory is registered or it declined; the caller then
falls back to the built-in ``KnowledgeStorage``.
"""
factory = _factory
return factory(embedder, collection_name) if factory is not None else None

View File

@@ -890,41 +890,17 @@ class BaseLLM(BaseModel, ABC):
Args:
usage_data: Token usage data from the API response
"""
prompt_tokens = (
usage_data.get("prompt_tokens")
or usage_data.get("prompt_token_count")
or usage_data.get("input_tokens")
or 0
)
metrics = UsageMetrics.from_provider_dict(usage_data)
if metrics is None:
return
completion_tokens = (
usage_data.get("completion_tokens")
or usage_data.get("candidates_token_count")
or usage_data.get("output_tokens")
or 0
)
cached_tokens = (
usage_data.get("cached_tokens")
or usage_data.get("cached_prompt_tokens")
or usage_data.get("cache_read_input_tokens")
or 0
)
if not cached_tokens:
prompt_details = usage_data.get("prompt_tokens_details")
if isinstance(prompt_details, dict):
cached_tokens = prompt_details.get("cached_tokens", 0) or 0
reasoning_tokens = usage_data.get("reasoning_tokens", 0) or 0
cache_creation_tokens = usage_data.get("cache_creation_tokens", 0) or 0
self._token_usage["prompt_tokens"] += prompt_tokens
self._token_usage["completion_tokens"] += completion_tokens
self._token_usage["total_tokens"] += prompt_tokens + completion_tokens
self._token_usage["successful_requests"] += 1
self._token_usage["cached_prompt_tokens"] += cached_tokens
self._token_usage["reasoning_tokens"] += reasoning_tokens
self._token_usage["cache_creation_tokens"] += cache_creation_tokens
self._token_usage["prompt_tokens"] += metrics.prompt_tokens
self._token_usage["completion_tokens"] += metrics.completion_tokens
self._token_usage["total_tokens"] += metrics.total_tokens
self._token_usage["successful_requests"] += metrics.successful_requests
self._token_usage["cached_prompt_tokens"] += metrics.cached_prompt_tokens
self._token_usage["reasoning_tokens"] += metrics.reasoning_tokens
self._token_usage["cache_creation_tokens"] += metrics.cache_creation_tokens
def get_token_usage_summary(self) -> UsageMetrics:
"""Get summary of token usage for this LLM instance.

View File

@@ -259,8 +259,9 @@ class RecallFlow(Flow[RecallState]):
candidates = []
if not candidates:
candidates = [scope_prefix]
self.state.candidate_scopes = candidates[:20]
return self.state.candidate_scopes
selected_scopes = candidates[:20]
self.state.candidate_scopes = selected_scopes
return selected_scopes
@listen(filter_and_chunk)
def search_chunks(self) -> list[Any]:
@@ -368,9 +369,10 @@ class RecallFlow(Flow[RecallState]):
)
)
matches.sort(key=lambda m: m.score, reverse=True)
self.state.final_results = matches[: self.state.limit]
final_results = matches[: self.state.limit]
self.state.final_results = final_results
if self.state.evidence_gaps and self.state.final_results:
self.state.final_results[0].evidence_gaps = list(self.state.evidence_gaps)
return self.state.final_results
return final_results

View File

@@ -0,0 +1,55 @@
"""Pluggable default storage backend for the unified memory system.
By default, :class:`~crewai.memory.unified_memory.Memory` builds a built-in
vector store from its ``storage`` spec string (LanceDB, or Qdrant for the
``"qdrant-edge"`` spec). Registering a factory via
:func:`set_memory_storage_factory` lets an application route memory through a
custom :class:`~crewai.memory.storage.backend.StorageBackend` -- a different
vector store, a remote service, an in-memory fake for tests -- without
subclassing ``Memory`` or threading an explicit ``storage=`` instance through
every construction site.
This mirrors :func:`crewai_core.lock_store.set_lock_backend`: a one-time,
process-wide setter intended for application startup. Pass ``None`` to restore
the built-in default.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from crewai.memory.storage.backend import StorageBackend
# Receives the raw ``storage`` spec string and returns a backend to use, or
# ``None`` to defer to the built-in selection for that spec.
MemoryStorageFactory = Callable[[str], "StorageBackend | None"]
_factory: MemoryStorageFactory | None = None
def set_memory_storage_factory(factory: MemoryStorageFactory | None) -> None:
"""Replace the process-wide default memory storage factory.
Intended for one-time setup at startup. Pass ``None`` to restore the
built-in LanceDB/Qdrant selection. Only affects ``Memory`` instances
constructed afterwards; an explicit ``storage=`` instance always wins.
The factory is consulted for every string ``storage`` spec, so it must
return ``None`` for specs it does not handle to let the built-in
LanceDB/Qdrant/path selection take over.
"""
global _factory
_factory = factory
def resolve_memory_storage(spec: str) -> StorageBackend | None:
"""Return the registered factory's backend for ``spec``, or ``None``.
``None`` means no factory is registered or it declined this spec; the
caller then falls back to the built-in selection.
"""
factory = _factory
return factory(spec) if factory is not None else None

View File

@@ -204,7 +204,12 @@ class Memory(BaseModel):
)
if isinstance(self.storage, str):
if self.storage == "qdrant-edge":
from crewai.memory.storage.factory import resolve_memory_storage
custom = resolve_memory_storage(self.storage)
if custom is not None:
self._storage = custom
elif self.storage == "qdrant-edge":
from crewai.memory.storage.qdrant_edge_storage import QdrantEdgeStorage
self._storage = QdrantEdgeStorage()

View File

@@ -1,5 +1,6 @@
"""Factory functions for creating RAG clients from configuration."""
from collections.abc import Callable
from typing import cast
from crewai.rag.config.optional_imports.protocols import (
@@ -11,6 +12,32 @@ from crewai.rag.core.base_client import BaseClient
from crewai.utilities.import_utils import require
# RAG uses a provider-keyed registry (rather than the single-default setter
# used by the memory/knowledge/flow seams) because ``create_client`` already
# dispatches on ``config.provider`` -- the natural seam here is per-provider.
# A factory receives the RAG config and returns a client; one registered for a
# built-in provider name overrides the built-in for that provider.
RagClientFactory = Callable[[RagConfigType], BaseClient]
_factories: dict[str, RagClientFactory] = {}
def register_rag_client_factory(provider: str, factory: RagClientFactory) -> None:
"""Register a client factory for a RAG ``provider`` name.
Lets an application plug in a client for a new provider, or override a
built-in provider (``"chromadb"`` / ``"qdrant"``), without modifying
:func:`create_client`. Registered factories take precedence over the
built-ins. Intended for one-time setup at startup.
"""
_factories[provider] = factory
def unregister_rag_client_factory(provider: str) -> None:
"""Remove a previously registered factory; a no-op if none is registered."""
_factories.pop(provider, None)
def create_client(config: RagConfigType) -> BaseClient:
"""Create a client from configuration using the appropriate factory.
@@ -24,6 +51,10 @@ def create_client(config: RagConfigType) -> BaseClient:
ValueError: If the configuration provider is not supported.
"""
factory = _factories.get(config.provider)
if factory is not None:
return factory(config)
if config.provider == "chromadb":
chromadb_mod = cast(
ChromaFactoryModule,

View File

@@ -30,7 +30,7 @@ from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
SpanExportResult,
)
from opentelemetry.trace import Span
from opentelemetry.trace import ProxyTracerProvider, Span
from typing_extensions import Self
from crewai.events.event_bus import crewai_event_bus
@@ -162,6 +162,10 @@ class Telemetry:
if self.ready and not self.trace_set:
try:
with suppress_warnings():
existing_provider = trace.get_tracer_provider()
if not isinstance(existing_provider, ProxyTracerProvider):
self.trace_set = True
return
trace.set_tracer_provider(self.provider)
self.trace_set = True
except Exception as e:

View File

@@ -4,10 +4,31 @@ This module provides models for tracking token usage and request metrics
during crew and agent execution.
"""
from typing import Any
from pydantic import BaseModel, Field
from typing_extensions import Self
def _coerce_int(value: Any) -> int:
if value is None:
return 0
try:
return int(value)
except (TypeError, ValueError):
return 0
def _first_int(usage_data: dict[str, Any], *keys: str) -> int:
"""Return the first integer-coercible value from ``usage_data`` under any
of ``keys``. Falls back to ``0`` when nothing matches."""
for key in keys:
coerced = _coerce_int(usage_data.get(key))
if coerced:
return coerced
return 0
class UsageMetrics(BaseModel):
"""Track usage metrics for crew execution.
@@ -54,3 +75,50 @@ class UsageMetrics(BaseModel):
self.reasoning_tokens += usage_metrics.reasoning_tokens
self.cache_creation_tokens += usage_metrics.cache_creation_tokens
self.successful_requests += usage_metrics.successful_requests
@classmethod
def from_provider_dict(cls, usage_data: dict[str, Any] | None) -> Self | None:
"""Normalize a provider's raw usage dict into a ``UsageMetrics``.
Accepts the full set of key aliases CrewAI providers emit:
``prompt_tokens`` / ``prompt_token_count`` (Gemini) / ``input_tokens``
(Anthropic), and the equivalent completion / cached-prompt aliases.
Mirrors ``BaseLLM._track_token_usage_internal`` so per-LLM totals,
flow-level aggregation, and OTel spans agree on every provider.
Returns ``None`` for missing/empty input so callers can decide
whether to skip the event entirely or treat it as a zero-token
successful request.
"""
if not usage_data:
return None
prompt_tokens = _first_int(
usage_data, "prompt_tokens", "prompt_token_count", "input_tokens"
)
completion_tokens = _first_int(
usage_data,
"completion_tokens",
"candidates_token_count",
"output_tokens",
)
cached_prompt_tokens = _first_int(
usage_data,
"cached_tokens",
"cached_prompt_tokens",
"cache_read_input_tokens",
)
if not cached_prompt_tokens:
details = usage_data.get("prompt_tokens_details")
if isinstance(details, dict):
cached_prompt_tokens = _coerce_int(details.get("cached_tokens"))
return cls(
total_tokens=prompt_tokens + completion_tokens,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cached_prompt_tokens=cached_prompt_tokens,
reasoning_tokens=_coerce_int(usage_data.get("reasoning_tokens")),
cache_creation_tokens=_coerce_int(usage_data.get("cache_creation_tokens")),
successful_requests=1,
)

View File

@@ -999,7 +999,11 @@ def _json_schema_to_pydantic_field(
if examples:
schema_extra["examples"] = examples
default = ... if is_required else None
default = (
json_schema["default"]
if "default" in json_schema
else (... if is_required else None)
)
if isinstance(type_, type) and issubclass(type_, (int, float)):
if "minimum" in json_schema:

View File

@@ -4,6 +4,7 @@ import os
import threading
from unittest import mock
from unittest.mock import MagicMock, patch
import warnings
from crewai.agents.crew_agent_executor import AgentFinish, CrewAgentExecutor
from crewai.constants import DEFAULT_LLM_MODEL
@@ -77,6 +78,51 @@ def test_agent_creation():
assert agent.backstory == "test backstory"
def test_agent_exposes_i18n_for_backward_compatibility():
from crewai.utilities.i18n import I18N_DEFAULT
agent = Agent(role="test role", goal="test goal", backstory="test backstory")
with pytest.warns(DeprecationWarning, match="Agent.i18n is deprecated"):
i18n = agent.i18n
assert i18n is I18N_DEFAULT
assert isinstance(i18n.slice("role_playing"), str)
def test_agent_accepts_custom_i18n():
from crewai.utilities.i18n import I18N
prompt_file = os.path.join(
os.path.dirname(__file__), "..", "utilities", "prompts.json"
)
i18n = I18N(prompt_file=prompt_file)
agent = Agent(
role="test role",
goal="test goal",
backstory="test backstory",
i18n=i18n,
)
with pytest.warns(DeprecationWarning, match="Agent.i18n is deprecated"):
agent_i18n = agent.i18n
assert agent_i18n is i18n
assert agent_i18n.slice("role_playing") == "Lorem ipsum dolor sit amet"
def test_agent_copy_does_not_emit_i18n_deprecation_warning():
agent = Agent(role="test role", goal="test goal", backstory="test backstory")
with warnings.catch_warnings(record=True) as caught_warnings:
warnings.simplefilter("always", DeprecationWarning)
agent.copy()
assert not any(
"Agent.i18n is deprecated" in str(w.message) for w in caught_warnings
)
def test_agent_with_only_system_template():
"""Test that an agent with only system_template works without errors."""
agent = Agent(

View File

@@ -32,7 +32,7 @@ def _build_executor(**kwargs: Any) -> AgentExecutor:
executor._method_outputs = []
executor._completed_methods = set()
executor._fired_or_listeners = set()
executor._pending_and_listeners = {}
executor._pending_events = {}
executor._method_execution_counts = {}
executor._method_call_counts = {}
executor._event_futures = []

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import threading
from typing import Any
from unittest.mock import patch
@@ -109,10 +110,79 @@ class TestCheckpointListenerOptsOut:
assert do_cp.call_count == 0
class TestFlowResumeReplaysEvents:
"""End-to-end: a resumed flow emits MethodExecution* events for completed methods."""
class TestCheckpointResumeReplaysEvents:
"""A flow resumed from a checkpoint replays MethodExecution* events for
completed methods and executes the pending ones. The checkpoint persists
the event record, which is reloaded into the per-run runtime state.
def test_resume_dispatches_completed_method_events(self, tmp_path) -> None:
``step_c`` is gated on a threading.Event so the flow is frozen with exactly
``step_a`` and ``step_b`` completed when the checkpoint is written — the
mid-run snapshot is deterministic rather than dependent on write timing.
"""
def test_resume_replays_completed_and_executes_pending(self, tmp_path) -> None:
from crewai.flow.flow import Flow, listen, start
from crewai.state.checkpoint_config import CheckpointConfig
at_step_c = threading.Event()
release = threading.Event()
captured: list[Any] = []
class ThreeStepFlow(Flow[dict]):
@start()
def step_a(self) -> str:
return "a"
@listen(step_a)
def step_b(self) -> str:
return "b"
@listen(step_b)
def step_c(self) -> str:
captured.append(crewai_event_bus.runtime_state)
at_step_c.set()
release.wait(timeout=10)
return "c"
runner = threading.Thread(target=ThreeStepFlow().kickoff)
runner.start()
try:
assert at_step_c.wait(timeout=10)
location = captured[0].checkpoint(str(tmp_path / "cp"))
finally:
release.set()
runner.join(timeout=10)
captured_started: list[str] = []
captured_finished: list[str] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionStartedEvent)
def _cs(_: Any, event: MethodExecutionStartedEvent) -> None:
captured_started.append(event.method_name)
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def _cf(_: Any, event: MethodExecutionFinishedEvent) -> None:
captured_finished.append(event.method_name)
ThreeStepFlow().kickoff(
from_checkpoint=CheckpointConfig(restore_from=location)
)
assert captured_started == ["step_a", "step_b", "step_c"]
assert captured_finished == ["step_a", "step_b", "step_c"]
class TestPersistResumeDoesNotReplayCompletedEvents:
"""A @persist resume continues from pending methods only.
@persist stores flow state, not the event record, so completed-method
events have no persisted source to replay from. Runtime state is scoped
per run, so flow1's events are not visible to flow2.
"""
def test_persist_resume_executes_only_pending_methods(self, tmp_path) -> None:
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
@@ -132,9 +202,6 @@ class TestFlowResumeReplaysEvents:
def step_c(self) -> str:
return "c"
if crewai_event_bus.runtime_state is not None:
crewai_event_bus.runtime_state.event_record.clear()
flow1 = ThreeStepFlow(persistence=persistence)
flow1.kickoff()
flow_id = flow1.state["id"]
@@ -157,9 +224,5 @@ class TestFlowResumeReplaysEvents:
flow2.kickoff(inputs={"id": flow_id})
assert captured_started.count("step_a") == 1
assert captured_started.count("step_b") == 1
assert captured_started.count("step_c") == 1
assert captured_finished.count("step_a") == 1
assert captured_finished.count("step_b") == 1
assert captured_finished.count("step_c") == 1
assert captured_started == ["step_c"]
assert captured_finished == ["step_c"]

View File

@@ -0,0 +1,130 @@
"""Tests for the pluggable knowledge storage factory seam.
We verify our own logic: the set/get round-trip, that a registered factory is
consulted when no explicit ``storage=`` is given (and receives the embedder and
collection name), and that an explicit ``storage=`` instance bypasses it.
"""
from __future__ import annotations
from typing import Any
import pytest
import crewai.knowledge.storage.factory as factory
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.storage.base_knowledge_storage import BaseKnowledgeStorage
from crewai.rag.types import SearchResult
class _FakeKnowledgeStorage(BaseKnowledgeStorage):
"""Minimal stand-in implementing the abstract interface."""
def search(
self,
query: list[str],
limit: int = 5,
metadata_filter: dict[str, Any] | None = None,
score_threshold: float = 0.6,
) -> list[SearchResult]:
return []
async def asearch(
self,
query: list[str],
limit: int = 5,
metadata_filter: dict[str, Any] | None = None,
score_threshold: float = 0.6,
) -> list[SearchResult]:
return []
def save(self, documents: list[str]) -> None:
return None
async def asave(self, documents: list[str]) -> None:
return None
def reset(self) -> None:
return None
async def areset(self) -> None:
return None
@pytest.fixture(autouse=True)
def reset_factory():
"""Reset the factory around each test without clobbering preexisting state."""
original = factory._factory
factory.set_knowledge_storage_factory(None)
yield
factory.set_knowledge_storage_factory(original)
def test_resolve_reflects_registered_factory():
fake = _FakeKnowledgeStorage()
assert factory.resolve_knowledge_storage(None, "docs") is None
factory.set_knowledge_storage_factory(lambda embedder, name: fake)
assert factory.resolve_knowledge_storage(None, "docs") is fake
def test_factory_used_when_no_explicit_storage():
fake = _FakeKnowledgeStorage()
factory.set_knowledge_storage_factory(lambda embedder, name: fake)
knowledge = Knowledge(collection_name="docs", sources=[])
assert knowledge.storage is fake
def test_factory_receives_embedder_and_collection_name():
seen: list[tuple[object, object]] = []
def make(embedder, collection_name):
seen.append((embedder, collection_name))
return _FakeKnowledgeStorage()
factory.set_knowledge_storage_factory(make)
Knowledge(collection_name="docs", sources=[])
assert seen == [(None, "docs")]
def test_explicit_storage_bypasses_factory():
factory_called = False
def make(embedder, name):
nonlocal factory_called
factory_called = True
return _FakeKnowledgeStorage()
factory.set_knowledge_storage_factory(make)
explicit = _FakeKnowledgeStorage()
knowledge = Knowledge(collection_name="docs", sources=[], storage=explicit)
assert knowledge.storage is explicit
assert factory_called is False
def test_falsy_explicit_storage_is_honored():
# A custom backend that is falsy (defines __bool__/__len__) must still be
# used and operated on, not silently treated as "not initialized" by a
# truthiness check in __init__, reset, or the source save path.
reset_calls: list[bool] = []
class _FalsyStorage(_FakeKnowledgeStorage):
def __bool__(self) -> bool:
return False
def reset(self) -> None:
reset_calls.append(True)
explicit = _FalsyStorage()
knowledge = Knowledge(collection_name="docs", sources=[], storage=explicit)
assert knowledge.storage is explicit
# reset must call the backend, not raise "Storage is not initialized."
knowledge.reset()
assert reset_calls == [True]

View File

@@ -0,0 +1,72 @@
"""Tests for the pluggable memory storage factory seam.
We verify our own logic: the set/get round-trip, that a registered factory is
consulted for string ``storage`` specs (and receives the spec), and that an
explicit ``storage=`` instance bypasses the factory entirely.
"""
from __future__ import annotations
import pytest
import crewai.memory.storage.factory as factory
from crewai.memory.unified_memory import Memory
@pytest.fixture(autouse=True)
def reset_factory():
"""Reset the factory around each test without clobbering preexisting state."""
original = factory._factory
factory.set_memory_storage_factory(None)
yield
factory.set_memory_storage_factory(original)
def test_resolve_reflects_registered_factory():
sentinel = object()
assert factory.resolve_memory_storage("lancedb") is None
factory.set_memory_storage_factory(lambda spec: sentinel)
assert factory.resolve_memory_storage("lancedb") is sentinel
factory.set_memory_storage_factory(None)
assert factory.resolve_memory_storage("lancedb") is None
def test_factory_backend_used_for_string_spec():
sentinel = object()
factory.set_memory_storage_factory(lambda spec: sentinel)
mem = Memory(storage="lancedb")
assert mem._storage is sentinel
def test_factory_receives_the_raw_spec():
seen: list[str] = []
def make(spec):
seen.append(spec)
return object()
factory.set_memory_storage_factory(make)
Memory(storage="some/custom/path")
assert seen == ["some/custom/path"]
def test_explicit_storage_instance_bypasses_factory():
factory_called = False
def make(spec):
nonlocal factory_called
factory_called = True
return object()
factory.set_memory_storage_factory(make)
explicit = object()
mem = Memory(storage=explicit) # type: ignore[arg-type]
assert mem._storage is explicit
assert factory_called is False

View File

@@ -0,0 +1,66 @@
"""Tests for the RAG client factory registry seam.
We verify our own logic: a registered factory is used for its provider,
factories override the built-in providers, unregister removes them, and an
unknown provider still raises.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import crewai.rag.factory as factory
@pytest.fixture(autouse=True)
def reset_registry():
"""Reset the registry around each test without clobbering preexisting state."""
original = dict(factory._factories)
factory._factories.clear()
yield
factory._factories.clear()
factory._factories.update(original)
def test_registered_factory_is_used_for_its_provider():
sentinel = object()
factory.register_rag_client_factory("custom", lambda config: sentinel)
assert factory.create_client(SimpleNamespace(provider="custom")) is sentinel
def test_factory_receives_the_config():
seen: list[object] = []
config = SimpleNamespace(provider="custom")
factory.register_rag_client_factory("custom", lambda cfg: seen.append(cfg) or object())
factory.create_client(config)
assert seen == [config]
def test_factory_overrides_builtin_provider():
sentinel = object()
factory.register_rag_client_factory("chromadb", lambda config: sentinel)
# Resolves via the registry without importing the built-in chromadb factory.
assert factory.create_client(SimpleNamespace(provider="chromadb")) is sentinel
def test_unregister_removes_factory():
factory.register_rag_client_factory("custom", lambda config: object())
factory.unregister_rag_client_factory("custom")
with pytest.raises(ValueError, match="Unsupported provider: custom"):
factory.create_client(SimpleNamespace(provider="custom"))
def test_unregister_unknown_provider_is_noop():
factory.unregister_rag_client_factory("never-registered")
def test_unknown_provider_still_raises():
with pytest.raises(ValueError, match="Unsupported provider: nope"):
factory.create_client(SimpleNamespace(provider="nope"))

View File

@@ -6,6 +6,7 @@ import pytest
from crewai import Agent, Crew, Task
from crewai.telemetry import Telemetry
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
@pytest.fixture(autouse=True)
@@ -53,6 +54,23 @@ def test_telemetry_enabled_by_default():
assert telemetry.ready is True
def test_set_tracer_skips_when_provider_already_configured():
"""A second telemetry instance must not re-install the global provider."""
with (
patch.dict(os.environ, {}, clear=True),
patch(
"crewai.telemetry.telemetry.trace.get_tracer_provider",
return_value=TracerProvider(),
),
patch("crewai.telemetry.telemetry.trace.set_tracer_provider") as mock_set,
):
telemetry = Telemetry()
telemetry.set_tracer()
mock_set.assert_not_called()
assert telemetry.trace_set is True
@patch("crewai.telemetry.telemetry.logger.error")
@patch(
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter.export",

View File

@@ -21,7 +21,7 @@ from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel
from crewai.flow import Flow, start, listen, human_feedback
from crewai.flow import Flow, HumanFeedbackResult, start, listen, human_feedback
from crewai.flow.async_feedback import (
ConsoleProvider,
HumanFeedbackPending,
@@ -615,6 +615,45 @@ class TestFlowResumeWithFeedback:
assert persistence.load_pending_feedback("resume-test-123") is None
@patch("crewai.flow.runtime.crewai_event_bus.emit")
def test_terminal_resume_without_emit_returns_feedback_result(
self, mock_emit: MagicMock
) -> None:
"""Terminal resumed non-emit methods return the full feedback result."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class TestFlow(Flow):
@start()
@human_feedback(message="Review this:", metadata={"stage": "draft"})
def generate(self):
return {"content": "generated content"}
context = PendingFeedbackContext(
flow_id="terminal-non-emit-test-123",
flow_class="test.TestFlow",
method_name="generate",
method_output={"content": "generated content"},
message="Review this:",
metadata={"stage": "draft"},
)
persistence.save_pending_feedback(
flow_uuid="terminal-non-emit-test-123",
context=context,
state_data={"id": "terminal-non-emit-test-123"},
)
flow = TestFlow.from_pending("terminal-non-emit-test-123", persistence)
result = flow.resume("looks good!")
assert isinstance(result, HumanFeedbackResult)
assert result.output == {"content": "generated content"}
assert result.feedback == "looks good!"
assert result.outcome is None
assert result.metadata == {"stage": "draft"}
assert flow.method_outputs == [result]
@patch("crewai.flow.runtime.crewai_event_bus.emit")
def test_resume_routing(self, mock_emit: MagicMock) -> None:
"""Test resume with routing."""
@@ -667,6 +706,93 @@ class TestFlowResumeWithFeedback:
assert flow.last_human_feedback.outcome == "approved"
assert flow.result_path == "approved"
@patch("crewai.flow.runtime.crewai_event_bus.emit")
def test_terminal_resume_with_emit_returns_method_output(
self, mock_emit: MagicMock
) -> None:
"""Terminal resumed emit methods return the original method output."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
method_output = {"content": "original content", "status": "ready"}
class TestFlow(Flow):
@start()
@human_feedback(
message="Approve?",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def review(self):
return method_output
context = PendingFeedbackContext(
flow_id="terminal-route-test-123",
flow_class="test.TestFlow",
method_name="review",
method_output=method_output,
message="Approve?",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
persistence.save_pending_feedback(
flow_uuid="terminal-route-test-123",
context=context,
state_data={"id": "terminal-route-test-123"},
)
flow = TestFlow.from_pending("terminal-route-test-123", persistence)
with patch.object(flow, "_collapse_to_outcome", return_value="approved"):
result = flow.resume("yes, this looks great")
assert result == method_output
assert flow.method_outputs == [method_output]
assert flow.last_human_feedback.outcome == "approved"
@patch("crewai.flow.runtime.crewai_event_bus.emit")
def test_resume_records_method_output_before_downstream_listeners(
self, mock_emit: MagicMock
) -> None:
"""Downstream listeners can read outputs from the resumed method."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class TestFlow(Flow):
@start()
@human_feedback(message="Review:")
def review(self):
return "generated content"
@listen(review)
def downstream(self, result):
self.state["seen_outputs"] = self.method_outputs
return f"downstream:{result.output}"
context = PendingFeedbackContext(
flow_id="listener-output-test-123",
flow_class="test.TestFlow",
method_name="review",
method_output="generated content",
message="Review:",
)
persistence.save_pending_feedback(
flow_uuid="listener-output-test-123",
context=context,
state_data={"id": "listener-output-test-123"},
)
flow = TestFlow.from_pending("listener-output-test-123", persistence)
result = flow.resume("looks good")
assert result == "downstream:generated content"
assert len(flow.state["seen_outputs"]) == 1
seen_output = flow.state["seen_outputs"][0]
assert isinstance(seen_output, HumanFeedbackResult)
assert seen_output.output == "generated content"
assert seen_output.feedback == "looks good"
# Integration Tests with @human_feedback decorator
@@ -1168,132 +1294,13 @@ class TestAsyncHumanFeedbackEdgeCases:
class TestLiveLLMPreservationOnResume:
"""Tests for preserving the full LLM config across HITL resume."""
def test_human_feedback_llm_attribute_set_on_wrapper_with_basellm(self) -> None:
"""Test that _human_feedback_llm is set on the wrapper when llm is a BaseLLM instance."""
from crewai.llms.base_llm import BaseLLM
mock_llm = MagicMock(spec=BaseLLM)
mock_llm.model = "gemini/gemini-3-flash"
class TestFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm=mock_llm,
)
def review(self):
return "content"
flow = TestFlow()
method = flow._methods.get("review")
assert method is not None
assert hasattr(method, "_human_feedback_llm")
assert method._human_feedback_llm is mock_llm
def test_human_feedback_llm_attribute_set_on_wrapper_with_string(self) -> None:
"""Test that _human_feedback_llm is set on the wrapper even when llm is a string."""
class TestFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def review(self):
return "content"
flow = TestFlow()
method = flow._methods.get("review")
assert method is not None
assert hasattr(method, "_human_feedback_llm")
assert method._human_feedback_llm == "gpt-4o-mini"
class TestResumeLLMFromSerializedContext:
"""Resume rebuilds the collapse LLM from the serialized context alone."""
@patch("crewai.flow.runtime.crewai_event_bus.emit")
def test_resume_async_uses_live_basellm_over_serialized_string(
def test_resume_builds_llm_from_serialized_context(
self, mock_emit: MagicMock
) -> None:
"""Test that resume_async uses the live BaseLLM from decorator instead of serialized string.
This is the main bug fix: when a flow resumes, it should use the fully-configured
LLM from the re-imported decorator (with credentials, project, etc.) instead of
creating a new LLM from just the model string.
"""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
from crewai.llms.base_llm import BaseLLM
# Create a mock BaseLLM with full config (simulating Gemini with service account)
live_llm = MagicMock(spec=BaseLLM)
live_llm.model = "gemini/gemini-3-flash"
class TestFlow(Flow):
result_path: str = ""
@start()
@human_feedback(
message="Approve?",
emit=["approved", "rejected"],
llm=live_llm,
)
def review(self):
return "content"
@listen("approved")
def handle_approved(self):
self.result_path = "approved"
return "Approved!"
context = PendingFeedbackContext(
flow_id="live-llm-test",
flow_class="TestFlow",
method_name="review",
method_output="content",
message="Approve?",
emit=["approved", "rejected"],
llm="gemini/gemini-3-flash", # Serialized string, NOT the live object
)
persistence.save_pending_feedback(
flow_uuid="live-llm-test",
context=context,
state_data={"id": "live-llm-test"},
)
flow = TestFlow.from_pending("live-llm-test", persistence)
captured_llm = []
def capture_llm(feedback, outcomes, llm):
captured_llm.append(llm)
return "approved"
with patch.object(flow, "_collapse_to_outcome", side_effect=capture_llm):
flow.resume("looks good!")
# NOT the serialized string. The live_llm was captured at class definition
# time and stored on the method wrapper as _human_feedback_llm.
assert len(captured_llm) == 1
# (which is stored on the method's _human_feedback_llm attribute)
method = flow._methods.get("review")
assert method is not None
assert captured_llm[0] is method._human_feedback_llm
# And verify it's a BaseLLM instance, not a string
assert isinstance(captured_llm[0], BaseLLM)
@patch("crewai.flow.runtime.crewai_event_bus.emit")
def test_resume_async_falls_back_to_serialized_string_when_no_human_feedback_llm(
self, mock_emit: MagicMock
) -> None:
"""Test that resume_async falls back to context.llm when _human_feedback_llm is not available.
This ensures backward compatibility with flows that were paused before this fix.
"""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
@@ -1325,11 +1332,6 @@ class TestLiveLLMPreservationOnResume:
flow = TestFlow.from_pending("fallback-test", persistence)
# Remove _human_feedback_llm to simulate old decorator without this attribute
method = flow._methods.get("review")
if hasattr(method, "_human_feedback_llm"):
delattr(method, "_human_feedback_llm")
captured_llm = []
def capture_llm(feedback, outcomes, llm):
@@ -1343,85 +1345,3 @@ class TestLiveLLMPreservationOnResume:
from crewai.llms.base_llm import BaseLLM as BaseLLMClass
assert isinstance(captured_llm[0], BaseLLMClass)
assert captured_llm[0].model == "gpt-4o-mini"
@patch("crewai.flow.runtime.crewai_event_bus.emit")
def test_resume_async_uses_string_from_context_when_human_feedback_llm_is_string(
self, mock_emit: MagicMock
) -> None:
"""Test that when _human_feedback_llm is a string (not BaseLLM), we still use context.llm.
String LLM values offer no benefit over the serialized context.llm,
so we don't prefer them.
"""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "test_flows.db")
persistence = SQLiteFlowPersistence(db_path)
class TestFlow(Flow):
@start()
@human_feedback(
message="Approve?",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
def review(self):
return "content"
context = PendingFeedbackContext(
flow_id="string-llm-test",
flow_class="TestFlow",
method_name="review",
method_output="content",
message="Approve?",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
)
persistence.save_pending_feedback(
flow_uuid="string-llm-test",
context=context,
state_data={"id": "string-llm-test"},
)
flow = TestFlow.from_pending("string-llm-test", persistence)
method = flow._methods.get("review")
assert method._human_feedback_llm == "gpt-4o-mini"
captured_llm = []
def capture_llm(feedback, outcomes, llm):
captured_llm.append(llm)
return "approved"
with patch.object(flow, "_collapse_to_outcome", side_effect=capture_llm):
flow.resume("looks good!")
# _human_feedback_llm is a string, so resume deserializes context.llm into an LLM instance
assert len(captured_llm) == 1
from crewai.llms.base_llm import BaseLLM as BaseLLMClass
assert isinstance(captured_llm[0], BaseLLMClass)
assert captured_llm[0].model == "gpt-4o-mini"
def test_human_feedback_llm_set_for_async_wrapper(self) -> None:
"""Test that _human_feedback_llm is set on async wrapper functions."""
import asyncio
from crewai.llms.base_llm import BaseLLM
mock_llm = MagicMock(spec=BaseLLM)
mock_llm.model = "gemini/gemini-3-flash"
class TestFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm=mock_llm,
)
async def async_review(self):
return "content"
flow = TestFlow()
method = flow._methods.get("async_review")
assert method is not None
assert hasattr(method, "_human_feedback_llm")
assert method._human_feedback_llm is mock_llm

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import inspect
import json
import os
import sqlite3
@@ -16,6 +17,7 @@ from pydantic import BaseModel
from crewai.agent.core import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.llms.base_llm import BaseLLM
from crewai.flow.flow import _INITIAL_STATE_CLASS_MARKER, Flow, start
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.state.checkpoint_listener import (
@@ -615,6 +617,44 @@ class TestKickoffFromCheckpoint:
class TestLegacyMethodOutputsRestore:
def test_restore_wraps_legacy_plain_value_outputs(self) -> None:
flow = Flow()
flow._method_outputs = ["first", "second"]
state = RuntimeState(root=[flow])
state._provider = JsonProvider()
with tempfile.TemporaryDirectory() as d:
loc = state.checkpoint(d)
cfg = CheckpointConfig(restore_from=loc)
restored = Flow.from_checkpoint(cfg)
assert restored.method_outputs == ["first", "second"]
def test_restore_legacy_outputs_evaluates_expressions(self) -> None:
from crewai.flow.runtime._expressions import _expression_context
flow = Flow()
flow._method_outputs = ["legacy"]
state = RuntimeState(root=[flow])
state._provider = JsonProvider()
with tempfile.TemporaryDirectory() as d:
loc = state.checkpoint(d)
cfg = CheckpointConfig(restore_from=loc)
restored = Flow.from_checkpoint(cfg)
context = _expression_context(restored)
assert context["outputs"] == {"": "legacy"}
def test_raw_legacy_outputs_remain_readable(self) -> None:
from crewai.flow.runtime._expressions import _expression_context
flow = Flow()
flow._method_outputs = ["legacy"]
assert flow.method_outputs == ["legacy"]
assert _expression_context(flow)["outputs"] == {"": "legacy"}
class TestAgentCheckpoint:
def _make_agent_state(self) -> RuntimeState:
agent = Agent(role="r", goal="g", backstory="b", llm="gpt-4o-mini")
@@ -682,3 +722,85 @@ class TestAgentCheckpoint:
cfg = CheckpointConfig(restore_from=loc)
restored = Agent.from_checkpoint(cfg)
assert restored._kickoff_event_id == "evt-456"
class _FinalAnswerLLM(BaseLLM):
"""Stub LLM that always returns a final answer without any API calls."""
def __init__(self) -> None:
super().__init__(model="stub")
def call(
self,
messages,
tools=None,
callbacks=None,
available_functions=None,
from_task=None,
from_agent=None,
response_model=None,
):
return "Final Answer: done."
def supports_function_calling(self) -> bool:
return False
def supports_stop_words(self) -> bool:
return False
def get_context_window_size(self) -> int:
return 4096
async def acall(self, *args, **kwargs):
raise NotImplementedError
class TestCheckpointReusedExecutor:
"""Checkpoint serialization stamps every live Flow's completed methods.
The agent executor is a Flow reused across a crew's tasks, so the stamp
must not be read back as a restore signal on the next task — otherwise the
second task replays as a resume and never reaches a final answer.
"""
def test_second_task_runs_with_checkpointing_enabled(self) -> None:
agent = Agent(role="r", goal="g", backstory="b", llm=_FinalAnswerLLM())
task1 = Task(description="first", expected_output="x", agent=agent)
task2 = Task(description="second", expected_output="y", agent=agent)
with tempfile.TemporaryDirectory() as d:
crew = Crew(
agents=[agent],
tasks=[task1, task2],
verbose=False,
checkpoint=CheckpointConfig(
provider=JsonProvider(location=d),
on_events=["task_started", "task_completed"],
),
)
result = crew.kickoff()
assert len(result.tasks_output) == 2
assert result.tasks_output[1].raw
class TestCustomLLMCheckpointRestore:
"""A custom BaseLLM subclass serializes with the inherited llm_type "base".
Restoring it must not try to instantiate the abstract BaseLLM; it is rebuilt
as a concrete LLM from the saved config instead.
"""
def test_restore_does_not_instantiate_abstract_base_llm(self) -> None:
agent = Agent(role="r", goal="g", backstory="b", llm=_FinalAnswerLLM())
task = Task(description="d", expected_output="e", agent=agent)
crew = Crew(agents=[agent], tasks=[task], verbose=False)
raw = RuntimeState(root=[crew]).model_dump_json()
restored = RuntimeState.model_validate_json(
raw, context={"from_checkpoint": True}
)
llm = restored.root[0].agents[0].llm
assert isinstance(llm, BaseLLM)
assert not inspect.isabstract(type(llm))
assert llm.model == "stub"

View File

@@ -409,4 +409,31 @@ class TestRuntimeStateIntegration:
old_json, context={"from_checkpoint": True}
)
assert len(restored.root) == 1
assert len(restored.event_record) == 0
assert len(restored.event_record) == 0
def test_reset_runtime_state_clears_state_and_registry(self):
from crewai import Agent, Crew, RuntimeState
from crewai.events.event_bus import crewai_event_bus
if RuntimeState is None:
pytest.skip("RuntimeState unavailable (model_rebuild failed)")
agent = Agent(role="test", goal="test", backstory="test", llm="gpt-4o-mini")
crew = Crew(agents=[agent], tasks=[], verbose=False)
previous_state = crewai_event_bus._runtime_state
previous_ids = crewai_event_bus._registered_entity_ids
crewai_event_bus._runtime_state = None
crewai_event_bus._registered_entity_ids = set()
try:
crewai_event_bus.register_entity(crew)
assert crewai_event_bus.runtime_state is not None
assert crewai_event_bus._registered_entity_ids
crewai_event_bus.reset_runtime_state()
assert crewai_event_bus.runtime_state is None
assert crewai_event_bus._registered_entity_ids == set()
finally:
crewai_event_bus._runtime_state = previous_state
crewai_event_bus._registered_entity_ids = previous_ids

View File

@@ -161,6 +161,27 @@ def test_flow_with_or_condition():
)
def test_flow_executes_and_condition_with_single_branch_or():
class NestedConditionFlow(Flow):
@start()
def event_a(self):
return "a"
@listen(event_a)
def event_b(self):
return "b"
@router(event_b)
def emit_event_c(self):
return "event_c"
@listen(and_(event_a, event_b, or_("event_c")))
def event_d(self):
return "done"
assert NestedConditionFlow().kickoff() == "done"
def test_or_listener_fires_once_across_parallel_starts():
"""Parallel ``@start`` paths feeding ``or_`` must not double-fire the listener."""
fire_count = 0
@@ -272,6 +293,121 @@ def test_flow_with_router():
assert execution_order == ["start_method", "router", "step_if_true"]
def test_start_runtime_uses_flow_definition_without_legacy_start_metadata():
execution_order = []
class DefinitionStartFlow(Flow):
@start()
def begin(self):
execution_order.append("begin")
return "begin"
@router(begin)
def route(self):
execution_order.append("route")
return "branch_event"
@start("branch_event")
def branch(self):
execution_order.append("branch")
return "branch"
@listen(branch)
def done(self):
execution_order.append("done")
assert not hasattr(DefinitionStartFlow.__dict__["begin"], "__is_start_method__")
assert not hasattr(DefinitionStartFlow.__dict__["branch"], "__trigger_methods__")
DefinitionStartFlow().kickoff()
assert execution_order == ["begin", "route", "branch", "done"]
def test_listen_runtime_uses_flow_definition_without_legacy_listener_metadata():
execution_order = []
class DefinitionListenFlow(Flow):
@start()
def begin(self):
execution_order.append("begin")
@listen(begin)
def by_callable(self):
execution_order.append("by_callable")
@listen(and_(begin, by_callable))
def by_and(self):
execution_order.append("by_and")
@listen(or_(and_(begin, by_callable), "fallback"))
def nested(self):
execution_order.append("nested")
for method_name in ("by_callable", "by_and", "nested"):
method = DefinitionListenFlow.__dict__[method_name]
assert not hasattr(method, "__trigger_methods__")
assert not hasattr(method, "__condition_type__")
assert not hasattr(method, "__trigger_condition__")
DefinitionListenFlow().kickoff()
assert execution_order[0] == "begin"
assert {"by_callable", "by_and", "nested"}.issubset(execution_order)
def test_router_runtime_uses_flow_definition_without_legacy_router_metadata():
execution_order = []
class DefinitionRouterFlow(Flow):
@start()
def begin(self):
execution_order.append("begin")
return "begin"
@router(begin, emit=["go_left"])
def decide(self):
execution_order.append("decide")
return "go_left"
@listen("go_left")
def handle_left(self):
execution_order.append("handle_left")
route = DefinitionRouterFlow.__dict__["decide"]
assert not hasattr(route, "__is_router__")
assert not hasattr(route, "__router_emit__")
assert not hasattr(route, "__trigger_methods__")
assert not hasattr(route, "__condition_type__")
assert not hasattr(route, "__trigger_condition__")
DefinitionRouterFlow().kickoff()
assert execution_order == ["begin", "decide", "handle_left"]
def test_router_falsy_result_emits_runtime_event():
execution_order = []
class FalsyRouterResultFlow(Flow):
@start()
def begin(self):
execution_order.append("begin")
@router(begin)
def decide(self):
execution_order.append("decide")
return 0
@listen("0")
def handle_zero(self):
execution_order.append("handle_zero")
FalsyRouterResultFlow().kickoff()
assert execution_order == ["begin", "decide", "handle_zero"]
def test_async_flow():
"""Test an asynchronous flow."""
execution_order = []
@@ -904,7 +1040,7 @@ def test_flow_plotting():
received_events.append(event)
event_received.set()
flow.plot("test_flow")
flow.plot("test_flow", show=False)
assert event_received.wait(timeout=5), "Timeout waiting for plot event"
assert len(received_events) == 1
@@ -1021,6 +1157,26 @@ def test_flow_name():
assert flow.name == "MyFlow"
def test_flow_custom_name_overrides_class_name_in_events():
class InternalFlowClass(Flow):
name = "PublicName"
@start()
def begin(self):
return "done"
received = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowStartedEvent)
def handle(source, event):
received.append(event)
InternalFlowClass().kickoff()
assert received[0].flow_name == "PublicName"
def test_nested_and_or_conditions():
"""Test nested conditions like or_(and_(A, B), and_(C, D)).
@@ -1405,6 +1561,66 @@ def test_deeply_nested_conditions():
assert and_ab_satisfied or and_cd_satisfied
def test_or_branch_does_not_leave_stale_and_state():
fired = []
class StaleStateFlow(Flow):
@start()
def begin(self):
pass
@listen(begin)
def a(self):
pass
@listen(begin)
def c(self):
pass
@listen(and_(a, c))
def x(self):
pass
@listen(or_(and_("a", "x"), and_("c", "y")))
def joined(self):
fired.append("joined")
@router(joined)
def emit_y(self):
return "y"
StaleStateFlow().kickoff()
assert fired == ["joined"]
def test_and_branch_inside_or_does_not_race():
execution_order = []
class DiamondWithFallbackFlow(Flow):
@start()
def go(self):
execution_order.append("go")
@listen(go)
def a(self):
execution_order.append("a")
@listen(go)
def b(self):
execution_order.append("b")
@listen(or_(and_(a, b), "fallback"))
def done(self):
execution_order.append("done")
DiamondWithFallbackFlow().kickoff()
assert "done" in execution_order
assert execution_order.index("done") > execution_order.index("a")
assert execution_order.index("done") > execution_order.index("b")
def test_mixed_sync_async_execution_order():
"""Test that execution order is preserved with mixed sync/async methods."""
execution_order = []

View File

@@ -6,6 +6,7 @@ from typing import Any, Literal
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from pydantic import BaseModel
from crewai.events.event_bus import crewai_event_bus
@@ -25,7 +26,11 @@ from crewai.experimental import (
RouterConfig,
)
from crewai.flow import Flow, ChatState, listen, start
from crewai.flow.flow_context import current_flow_id, current_flow_name
from crewai.flow.flow_context import (
current_flow_defer_trace_finalization,
current_flow_id,
current_flow_name,
)
from crewai.flow.conversation import (
append_message,
get_conversation_messages,
@@ -33,6 +38,16 @@ from crewai.flow.conversation import (
prepare_conversational_turn,
)
# The built-in conversational graph lives on ``_ConversationalMixin`` and is
# inherited by ``conversational = True`` subclasses. The definition-first start
# migration intentionally stopped scanning inherited methods, so that graph no
# longer registers. These end-to-end conversational tests are out of scope
# until conversational mode is migrated onto the FlowDefinition.
conversational_graph_broken = pytest.mark.skip(
reason="Experimental conversational registry behavior is out of scope for "
"the definition-first start migration."
)
class ConversationalFlow(Flow[ConversationState]):
"""Test base: a ``Flow[ConversationState]`` with conversational mode enabled.
@@ -176,7 +191,6 @@ class TestConversationalFlow:
result = flow.handle_turn("research CrewAI")
assert result == "researched answer"
assert "conversation_start" in ResearchFlow._start_methods
assert flow.state.current_user_message == "research CrewAI"
assert flow.state.last_intent == "research"
assert [message.role for message in flow.state.messages] == [
@@ -187,6 +201,7 @@ class TestConversationalFlow:
assert flow.state.events[0].agent_name == "researcher"
assert flow.state.events[0].visibility == "public"
@conversational_graph_broken
def test_private_agent_results_stay_out_of_shared_history(self) -> None:
class PrivateFlow(ConversationalFlow):
def route_turn(self, context: dict[str, Any]) -> str | None:
@@ -203,6 +218,7 @@ class TestConversationalFlow:
assert flow.state.events[0].visibility == "private"
assert flow.state.agent_threads["planner"][0].content == "private scratch"
@conversational_graph_broken
def test_answer_from_history_uses_configured_llm_and_appends_reply(self) -> None:
@ConversationConfig(answer_from_history_llm="gpt-4o-mini")
class HistoryFlow(ConversationalFlow):
@@ -233,6 +249,7 @@ class TestConversationalFlow:
assert flow.state.messages[-1].content == "summary from history"
llm.call.assert_called_once()
@conversational_graph_broken
def test_router_config_uses_structured_intent_response(self) -> None:
class ResearchRoute(BaseModel):
intent: Literal["research", "clarify"]
@@ -269,6 +286,7 @@ class TestConversationalFlow:
assert llm.call.call_args.kwargs["response_format"] is ResearchRoute
assert flow.state.messages[-1].content == "researched"
@conversational_graph_broken
def test_router_config_falls_back_for_invalid_intent(self) -> None:
class ResearchRoute(BaseModel):
intent: str
@@ -327,6 +345,7 @@ class TestConversationalFlow:
"end",
}
@conversational_graph_broken
def test_router_infers_custom_routes_without_internal_routes(self) -> None:
class ResearchRoute(BaseModel):
intent: Literal["research", "converse", "end"]
@@ -350,6 +369,7 @@ class TestConversationalFlow:
"end",
}
@conversational_graph_broken
def test_router_config_uses_conversational_defaults(self) -> None:
llm = MagicMock()
@@ -376,6 +396,7 @@ class TestConversationalFlow:
)
assert flow.state.messages[-1].content == "researched"
@conversational_graph_broken
def test_builtin_converse_appends_assistant_message_and_uses_history(self) -> None:
class ResearchRoute(BaseModel):
intent: Literal["research", "converse", "end"]
@@ -423,6 +444,7 @@ class TestConversationalFlow:
assert any(message["content"] == "prior findings" for message in messages)
assert any(message["content"] == "summarize findings" for message in messages)
@conversational_graph_broken
def test_conversational_turn_emits_message_and_route_events(self) -> None:
class ResearchRoute(BaseModel):
intent: Literal["research", "converse", "end"]
@@ -473,6 +495,7 @@ class TestConversationalFlow:
assert routes[0].user_message == "just chat"
assert routes[0].session_id == messages[0].session_id
@conversational_graph_broken
def test_builtin_end_marks_conversation_ended(self) -> None:
class ResearchRoute(BaseModel):
intent: Literal["research", "converse", "end"]
@@ -501,6 +524,7 @@ class TestConversationalFlow:
assert flow.state.ended is True
assert flow.state.messages[-1].content == "Conversation ended."
@conversational_graph_broken
def test_router_auto_enables_when_custom_routes_declared_and_no_explicit_config(
self,
) -> None:
@@ -533,6 +557,7 @@ class TestConversationalFlow:
# Router LLM should have been invoked.
assert router_llm.call.call_count >= 1
@conversational_graph_broken
def test_router_auto_enable_skipped_when_only_builtin_routes(self) -> None:
"""No custom routes → no auto-enable; falls through to converse."""
@@ -550,6 +575,7 @@ class TestConversationalFlow:
# chat_llm was used by converse_turn, not as a router.
assert chat_llm.call.call_count == 1
@conversational_graph_broken
def test_router_auto_enable_skipped_when_default_intents_set(self) -> None:
"""Legacy ``default_intents`` opts out of router auto-enable."""
@@ -576,9 +602,9 @@ class TestConversationalFlow:
"""Conversational flows: user ``@start`` methods finish before router fires.
Non-chat flows run ``@start`` methods in parallel via ``asyncio.gather``,
which would race with ``conversation_start`` and let the router fire
which would race with ``route_conversation`` and let the router fire
before user setup finished. In conversational mode the framework runs
them sequentially, with ``conversation_start`` last.
them sequentially, with ``route_conversation`` last.
"""
order: list[str] = []
@@ -621,15 +647,10 @@ class TestConversationalFlow:
assert "attach_bus" in order # still fires every turn
assert "route_turn" in order
def test_subclass_can_override_conversation_start_without_redecorating(
def test_subclass_can_override_conversation_start_helper(
self,
) -> None:
"""Overriding an inherited ``@start`` method must not unregister it.
Before the metaclass fix, subclasses had to re-apply ``@start()`` on
every override or the parent's ``conversation_start`` would silently
drop out of ``_start_methods`` — leaving the flow with nothing to fire.
"""
"""The compatibility helper remains overridable without adding a Flow node."""
bootstrap_calls: list[str] = []
@@ -648,13 +669,44 @@ class TestConversationalFlow:
return "worked"
flow = BootstrapFlow()
assert "conversation_start" in flow._start_methods
flow.handle_turn("hi")
assert bootstrap_calls == ["ran"]
assert "conversation_start" not in BootstrapFlow.flow_definition().methods
route_definition = BootstrapFlow.flow_definition().methods["route_conversation"]
assert route_definition.start is True
assert route_definition.router is True
assert flow.state.messages[-1].content == "worked"
def test_legacy_decorated_conversation_start_runs_once_per_turn(
self,
) -> None:
"""Legacy ``@start`` overrides are not invoked again by the router."""
bootstrap_calls: list[str] = []
@ConversationConfig()
class BootstrapFlow(ConversationalFlow):
@start()
def conversation_start(self) -> str | None:
bootstrap_calls.append("ran")
return super().conversation_start()
def route_turn(self, context: dict[str, Any]) -> str | None:
return "work"
@listen("work")
def do_work(self) -> str:
self.append_assistant_message("worked")
return "worked"
flow = BootstrapFlow()
flow.handle_turn("hi")
assert bootstrap_calls == ["ran"]
assert flow.state.messages[-1].content == "worked"
@conversational_graph_broken
def test_handle_turn_reruns_graph_after_prior_turn_completed(self) -> None:
"""Multi-turn must not flip ``_is_execution_resuming`` and short-circuit.
@@ -710,6 +762,7 @@ class TestConversationalFlow:
assert flow.state.messages[-1].content == "fresh research"
assert flow._is_execution_resuming is False
@conversational_graph_broken
def test_route_catalog_combines_docstrings_builtins_and_overrides(self) -> None:
"""Catalog precedence: route_descriptions > built-in > docstring."""
@@ -741,6 +794,7 @@ class TestConversationalFlow:
assert "Ordinary chat" in catalog["converse"]
assert "finished" in catalog["end"]
@conversational_graph_broken
def test_route_catalog_falls_back_to_empty_when_no_docstring(self) -> None:
@ConversationConfig(router=RouterConfig(routes=["BARE"]))
class BareFlow(ConversationalFlow):
@@ -753,6 +807,7 @@ class TestConversationalFlow:
assert catalog["BARE"] == ""
@conversational_graph_broken
def test_router_messages_include_route_catalog(self) -> None:
"""The router system prompt must enumerate routes with descriptions."""
@@ -786,6 +841,7 @@ class TestConversationalFlow:
assert "- converse: Ordinary chat" in system_message
assert system_message.startswith("A research-focused assistant.")
@conversational_graph_broken
def test_router_decision_persists_last_intent_and_passes_it_next_turn(
self,
) -> None:
@@ -830,6 +886,7 @@ class TestConversationalFlow:
]
assert '"last_intent": "research"' in second_call_user_content
@conversational_graph_broken
def test_custom_route_still_runs_with_builtin_routes(self) -> None:
class ResearchRoute(BaseModel):
intent: Literal["research", "converse", "end"]
@@ -878,6 +935,7 @@ class TestConversationalFlow:
assert flow.state.current_user_message is None
assert flow.state.session_ready is False
@conversational_graph_broken
def test_mixin_handle_turn_resolves_on_flow_subclass(self) -> None:
"""``Flow`` mixes in ``_ConversationalMixin`` — opt-in subclasses get its methods.
@@ -910,6 +968,7 @@ class TestConversationalFlow:
flow.handle_turn("anything")
assert flow.state.messages[-1].content == "worked"
@conversational_graph_broken
def test_chat_runs_repl_over_handle_turn_and_finalizes(self) -> None:
@ConversationConfig(defer_trace_finalization=False)
class MyChat(ConversationalFlow):
@@ -950,6 +1009,7 @@ class TestConversationalFlow:
mock_finalize.assert_called_once_with()
assert flow.defer_trace_finalization is False
@conversational_graph_broken
def test_chat_stringifies_repl_output_like_conversation_helpers(self) -> None:
class RawResult:
raw = "raw assistant output"
@@ -1141,6 +1201,40 @@ class TestConversationalFlow:
"finalize_session_traces must finalize the trace batch once"
)
def test_deferred_resume_skips_per_resume_flow_finished_event(self) -> None:
"""Deferred sessions do not emit terminal events while resuming."""
from crewai.events.types.flow_events import FlowFinishedEvent
from crewai.flow.async_feedback.types import PendingFeedbackContext
class DeferredResumeFlow(Flow[ChatState]):
defer_trace_finalization = True
@start()
def begin(self) -> str:
return "started"
flow = DeferredResumeFlow()
flow._pending_feedback_context = PendingFeedbackContext(
flow_id=flow.flow_id,
flow_class="DeferredResumeFlow",
method_name="begin",
method_output="started",
message="Review",
)
finished_events: list[FlowFinishedEvent] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowFinishedEvent)
def capture(_: Any, event: FlowFinishedEvent) -> None:
finished_events.append(event)
flow.resume("approved")
crewai_event_bus.flush()
assert finished_events == []
def test_finalize_session_traces_restores_event_scope(self, capsys) -> None:
"""No ``empty scope stack`` warning when deferred ``flow_finished`` fires.
@@ -1243,7 +1337,11 @@ class TestFlowTracingWhenSuppressed:
assert started == ["QuietFlow"]
def test_method_execution_emitted_when_panel_events_suppressed(self) -> None:
def test_method_execution_suppressed_when_flow_events_suppressed(self) -> None:
"""``suppress_flow_events=True`` silences MethodExecution events so
infrastructure flows (AgentExecutor, memory) don't emit one trace span
per internal control-flow method."""
class QuietFlow(Flow[ChatState]):
suppress_flow_events = True
@@ -1265,8 +1363,8 @@ class TestFlowTracingWhenSuppressed:
with patch.object(crewai_event_bus, "emit", side_effect=track_emit):
QuietFlow().kickoff()
assert started == ["begin"]
assert finished == ["begin"]
assert started == []
assert finished == []
def test_llm_action_inside_flow_claims_flow_trace_batch(self) -> None:
listener = TraceCollectionListener()
@@ -1300,6 +1398,12 @@ class TestFlowTracingWhenSuppressed:
class TestDeferTraceFinalization:
def test_bare_conversational_flow_defers_by_default(self) -> None:
class BareChat(ConversationalFlow):
pass
assert BareChat()._should_defer_trace_finalization() is True
def test_conversation_config_drives_defer_flag(self) -> None:
"""``ConversationConfig(defer_trace_finalization=...)`` controls whether
a conversational subclass defers per-turn trace finalization."""
@@ -1432,6 +1536,44 @@ class TestDeferredFlowLifecycleEvents:
listener.batch_manager.finalize_batch()
mock_finalize.assert_not_called()
def test_deferred_flow_kickoff_marks_trace_manager_session_deferred(
self,
) -> None:
class DeferredTraceFlow(Flow[ChatState]):
@start()
def begin(self) -> str:
return "done"
listener = TraceCollectionListener()
listener.batch_manager.defer_session_finalization = False
flow = DeferredTraceFlow()
flow.defer_trace_finalization = True
with patch.object(listener.batch_manager, "finalize_batch"):
flow.kickoff()
assert listener.batch_manager.defer_session_finalization is True
flow.finalize_session_traces()
assert listener.batch_manager.defer_session_finalization is False
def test_non_deferred_flow_kickoff_clears_stale_trace_manager_flag(
self,
) -> None:
class PlainTraceFlow(Flow[ChatState]):
@start()
def begin(self) -> str:
return "done"
listener = TraceCollectionListener()
listener.batch_manager.defer_session_finalization = True
PlainTraceFlow().kickoff()
assert listener.batch_manager.defer_session_finalization is False
class TestNestedCrewTracing:
def test_is_inside_active_flow_context_when_kickoff_running(self) -> None:
@@ -1485,3 +1627,130 @@ class TestNestedCrewTracing:
elif listener.batch_manager.batch_owner_type == "crew":
listener.batch_manager.finalize_batch()
mock_finalize.assert_not_called()
def test_lazy_flow_batch_from_context_preserves_deferred_parent(self) -> None:
from crewai.events.listeners.tracing.trace_listener import (
TraceCollectionListener,
)
listener = TraceCollectionListener()
listener.batch_manager.current_batch = None
listener.batch_manager.batch_owner_type = None
listener.batch_manager.batch_owner_id = None
listener.batch_manager.defer_session_finalization = False
listener.batch_manager.event_buffer.clear()
flow_id_token = current_flow_id.set("parent-flow-id")
flow_name_token = current_flow_name.set("ParentChatFlow")
defer_token = current_flow_defer_trace_finalization.set(True)
try:
initialized = listener._try_initialize_flow_batch_from_context(
type("Event", (), {"timestamp": None})()
)
assert initialized is True
assert listener.batch_manager.batch_owner_type == "flow"
assert listener.batch_manager.batch_owner_id == "parent-flow-id"
assert listener.batch_manager.defer_session_finalization is True
assert listener.batch_manager.current_batch is not None
assert (
listener.batch_manager.current_batch.execution_metadata[
"execution_type"
]
== "flow"
)
assert (
listener.batch_manager.current_batch.execution_metadata["flow_name"]
== "ParentChatFlow"
)
finally:
current_flow_defer_trace_finalization.reset(defer_token)
current_flow_name.reset(flow_name_token)
current_flow_id.reset(flow_id_token)
listener.batch_manager.current_batch = None
listener.batch_manager.batch_owner_type = None
listener.batch_manager.batch_owner_id = None
listener.batch_manager.trace_batch_id = None
listener.batch_manager.defer_session_finalization = False
listener.batch_manager.event_buffer.clear()
def test_nested_agent_executor_flow_does_not_finalize_parent_batch(
self,
) -> None:
from crewai import Agent, Crew, Task
from crewai.llms.base_llm import BaseLLM
class StaticLLM(BaseLLM):
def __init__(self) -> None:
super().__init__(model="debug-static-llm", provider="debug")
def call(
self,
messages: Any,
tools: Any = None,
callbacks: Any = None,
available_functions: Any = None,
from_task: Any = None,
from_agent: Any = None,
response_model: Any = None,
) -> str:
return (
"Thought: I can answer directly.\n"
"Final Answer: nested crew result"
)
class NestedCrewFlow(Flow[ChatState]):
defer_trace_finalization = True
tracing = True
@start()
def begin(self) -> str:
return "run_nested_crew"
@listen(begin)
def run_nested_crew(self, _: str) -> str:
agent = Agent(
role="Debug Agent",
goal="Return a short deterministic result",
backstory="Used only for trace finalization debugging.",
llm=StaticLLM(),
verbose=False,
)
task = Task(
description="Return the deterministic nested crew result.",
expected_output="nested crew result",
agent=agent,
)
return Crew(agents=[agent], tasks=[task], verbose=False).kickoff().raw
listener = TraceCollectionListener()
listener.batch_manager.current_batch = None
listener.batch_manager.batch_owner_type = None
listener.batch_manager.batch_owner_id = None
listener.batch_manager.trace_batch_id = None
listener.batch_manager.defer_session_finalization = False
listener.batch_manager.event_buffer.clear()
listener.first_time_handler.is_first_time = False
def initialize_backend_batch(*_: Any, **__: Any) -> None:
listener.batch_manager.trace_batch_id = "debug-trace-batch"
flow = NestedCrewFlow()
with (
patch.object(
listener.batch_manager,
"_initialize_backend_batch",
side_effect=initialize_backend_batch,
),
patch.object(listener.batch_manager, "finalize_batch") as mock_finalize,
):
flow.kickoff()
crewai_event_bus.flush()
flow.kickoff()
crewai_event_bus.flush()
assert mock_finalize.call_count == 0, (
"nested AgentExecutor flows inside a deferred parent Flow must "
"not finalize the parent trace batch"
)

View File

@@ -1,6 +1,5 @@
"""Tests for the static Flow Definition contract."""
import ast
from enum import Enum
import importlib
import inspect
@@ -8,13 +7,14 @@ import logging
from pathlib import Path
from typing import Annotated, Literal
import pytest
from pydantic import BaseModel
import crewai.flow.dsl as flow_dsl
import crewai.flow.flow_definition as flow_definition
import crewai.flow.visualization.builder as visualization_builder
from crewai.experimental import ConversationConfig, RouterConfig
from crewai.flow import Flow, and_, human_feedback, listen, or_, persist, router, start
from crewai.flow.dsl._conditions import is_flow_condition_dict
def test_flow_public_exports_are_explicit():
@@ -36,92 +36,83 @@ def test_flow_public_exports_are_explicit():
"start",
}
assert set(flow_definition.__all__) == {
"FlowActionDefinition",
"FlowCodeActionDefinition",
"FlowConfigDefinition",
"FlowConversationalDefinition",
"FlowConversationalRouterDefinition",
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowMethodDefinition",
"FlowPersistenceDefinition",
"FlowStateDefinition",
"FlowToolActionDefinition",
}
assert "build_flow_structure" in flow_visualization.__all__
assert "calculate_node_levels" not in flow_visualization.__all__
def test_flow_condition_dict_accepts_non_string_sequences():
condition = {
"type": "OR",
"conditions": (
"approved",
{"type": "AND", "methods": ("validated", "processed")},
),
def test_condition_combinators_return_nested_runtime_tree():
condition = and_("event_a", "event_b", or_("event_c"))
assert condition == {
"type": "AND",
"conditions": [
"event_a",
"event_b",
{"type": "OR", "conditions": ["event_c"]},
],
}
assert is_flow_condition_dict(condition)
assert not is_flow_condition_dict({"type": "OR", "conditions": "approved"})
assert not is_flow_condition_dict({"type": "OR", "methods": b"approved"})
def test_flow_definition_lowers_nested_conditions():
class NestedFlow(Flow):
@start()
def begin(self):
return "begin"
@listen(begin)
def validated(self):
return "validated"
@listen(begin)
def processed(self):
return "processed"
@listen(or_(and_(validated, processed), begin))
def finalize(self):
return "done"
finalize = NestedFlow.flow_definition().methods["finalize"]
assert finalize.listen == {"or": [{"and": ["validated", "processed"]}, "begin"]}
def test_private_flow_helpers_do_not_have_docstrings():
import crewai.flow.flow_wrappers as flow_wrappers
import crewai.flow.human_feedback as human_feedback
import crewai.flow.persistence.decorators as persistence_decorators
import crewai.flow.visualization.types as visualization_types
def test_flow_definition_preserves_single_branch_nested_conditions():
class AmbiguousFlow(Flow):
@start()
def event_a(self):
return "a"
modules = [
flow_dsl,
flow_definition,
flow_wrappers,
human_feedback,
persistence_decorators,
visualization_builder,
visualization_types,
]
violations: list[str] = []
@listen(event_a)
def event_b(self):
return "b"
for module in modules:
source_path = Path(inspect.getsourcefile(module) or "")
tree = ast.parse(source_path.read_text())
stack: list[ast.AST] = []
if getattr(module, "__all__", None) == [] and ast.get_docstring(tree):
violations.append(f"{source_path}:1:<module>")
@listen(and_(event_a, event_b, or_("event_c")))
def event_d(self):
return "d"
class PrivateDocstringVisitor(ast.NodeVisitor):
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self._check_docstring(node)
stack.append(node)
self.generic_visit(node)
stack.pop()
event_d = AmbiguousFlow.flow_definition().methods["event_d"]
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._check_docstring(node)
stack.append(node)
self.generic_visit(node)
stack.pop()
assert event_d.listen == {"and": ["event_a", "event_b", {"or": ["event_c"]}]}
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._check_docstring(node)
stack.append(node)
self.generic_visit(node)
stack.pop()
def _check_docstring(
self,
node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
) -> None:
is_dunder = node.name.startswith("__") and node.name.endswith("__")
is_private_name = node.name.startswith("_") and not is_dunder
is_nested_function = any(
isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef))
for parent in stack
)
if (is_private_name or is_nested_function) and ast.get_docstring(node):
violations.append(f"{source_path}:{node.lineno}:{node.name}")
PrivateDocstringVisitor().visit(tree)
assert violations == []
def test_flow_definition_rejects_invalid_condition():
with pytest.raises(ValueError, match="Invalid condition"):
start(123)(lambda self: None)
def test_flow_definition_contract_is_dsl_agnostic():
@@ -185,6 +176,7 @@ def test_flow_definition_maps_dsl_to_static_contract():
assert definition.state.ref and "ContractState" in definition.state.ref
assert definition.config.stream is True
assert definition.config.max_method_calls == 7
assert definition.conversational is None
assert definition.methods["begin"].start is True
assert definition.methods["process"].listen == "begin"
@@ -217,6 +209,7 @@ def test_flow_definition_excludes_conversational_builtins_for_regular_flows():
methods = RegularFlow.flow_definition().methods
assert RegularFlow.flow_definition().conversational is None
assert set(methods) == {"begin"}
assert "conversation_start" not in methods
assert "route_conversation" not in methods
@@ -227,12 +220,64 @@ def test_flow_definition_includes_conversational_builtins_when_enabled():
class ChatFlow(Flow):
conversational = True
methods = ChatFlow.flow_definition().methods
definition = ChatFlow.flow_definition()
methods = definition.methods
assert "conversation_start" in methods
assert definition.conversational is not None
assert definition.conversational.enabled is True
assert definition.conversational.defer_trace_finalization is True
assert definition.conversational.builtin_routes == ["converse", "end"]
assert "conversation_start" not in methods
assert "route_conversation" in methods
assert "converse_turn" in methods
assert methods["conversation_start"].start is True
assert methods["route_conversation"].start is True
assert methods["route_conversation"].router is True
def test_flow_definition_serializes_conversational_config():
@ConversationConfig(
system_prompt="Be concise.",
llm="gpt-4o-mini",
router=RouterConfig(
prompt="Pick a route.",
routes=["research"],
default_intent="converse",
fallback_intent="end",
),
default_intents=["research"],
visible_agent_outputs=["researcher"],
defer_trace_finalization=False,
)
class ChatFlow(Flow):
conversational = True
conversational = ChatFlow.flow_definition().conversational
assert conversational is not None
assert conversational.system_prompt == "Be concise."
assert conversational.llm == "gpt-4o-mini"
assert conversational.default_intents == ["research"]
assert conversational.visible_agent_outputs == ["researcher"]
assert conversational.defer_trace_finalization is False
assert conversational.router is not None
assert conversational.router.prompt == "Pick a route."
assert conversational.router.routes == ["research"]
assert conversational.router.fallback_intent == "end"
def test_flow_definition_uses_collapsed_conversational_router_start():
class ChatFlow(Flow):
conversational = True
def conversation_start(self) -> str | None:
return "custom"
methods = ChatFlow.flow_definition().methods
assert "conversation_start" not in methods
assert "route_conversation" in methods
assert methods["route_conversation"].start is True
assert methods["route_conversation"].router is True
def test_flow_definition_serializes_human_feedback_metadata():
@@ -298,82 +343,13 @@ def test_flow_definition_fragments_cover_start_listen_and_condition_sugar():
"or": [{"and": ["manual_event", "by_string"]}, "fallback_event"]
}
assert set(FragmentFlow._start_methods) == {"begin", "restart"}
assert FragmentFlow._listeners["restart"] == ("OR", ["restart_event"])
assert FragmentFlow._listeners["by_callable"] == ("OR", ["begin"])
assert FragmentFlow._listeners["by_string"] == ("OR", ["manual_event"])
assert FragmentFlow._listeners["by_and"] == {
"type": "AND",
"conditions": ["begin", "by_callable"],
}
assert FragmentFlow._listeners["nested"] == {
"type": "OR",
"conditions": [
{"type": "AND", "conditions": ["manual_event", "by_string"]},
"fallback_event",
],
}
def test_extract_flow_definition_prefers_fragments_over_legacy_metadata():
class RegistryFlow(Flow):
@start()
def begin(self):
return "begin"
@listen(begin)
def handle(self):
return "handle"
@router(handle, emit=["done"])
def decide(self):
return "done"
handle = RegistryFlow.__dict__["handle"]
original_trigger_methods = handle.__trigger_methods__
handle.__trigger_methods__ = ["wrong"]
try:
_, listeners, routers, router_emit = flow_dsl.extract_flow_definition(
{
"begin": RegistryFlow.__dict__["begin"],
"handle": handle,
"decide": RegistryFlow.__dict__["decide"],
}
)
finally:
handle.__trigger_methods__ = original_trigger_methods
assert listeners["handle"] == ("OR", ["begin"])
assert listeners["decide"] == ("OR", ["handle"])
assert routers == {"decide"}
assert router_emit == {"decide": ["done"]}
def test_flow_definition_falls_back_to_legacy_metadata_without_fragment():
class LegacyMetadataFlow(Flow):
@start()
def begin(self):
return "begin"
@router(begin, emit=["left"])
def decide(self):
return "left"
@listen("left")
def left(self):
return "left"
for method_name in ("begin", "decide", "left"):
method = LegacyMetadataFlow.__dict__[method_name]
delattr(method, "__flow_method_definition__")
definition = flow_dsl.build_flow_definition(LegacyMetadataFlow)
assert definition.methods["begin"].start is True
assert definition.methods["decide"].listen == "begin"
assert definition.methods["decide"].router is True
assert definition.methods["decide"].emit == ["left"]
assert definition.methods["left"].listen == "left"
assert not hasattr(FragmentFlow.__dict__["begin"], "__is_start_method__")
assert not hasattr(FragmentFlow.__dict__["restart"], "__trigger_methods__")
for method_name in ("by_callable", "by_string", "by_and", "nested"):
method = FragmentFlow.__dict__[method_name]
assert not hasattr(method, "__trigger_methods__")
assert not hasattr(method, "__condition_type__")
assert not hasattr(method, "__trigger_condition__")
def test_human_feedback_emit_overrides_inner_router_emit():
@@ -395,9 +371,6 @@ def test_human_feedback_emit_overrides_inner_router_emit():
def proceed(self):
return "ok"
assert "route" in FeedbackOverRouterFlow._routers
assert FeedbackOverRouterFlow._router_emit["route"] == ["approved", "rejected"]
route = FeedbackOverRouterFlow.flow_definition().methods["route"]
assert route.router is True
assert route.human_feedback is not None
@@ -660,6 +633,7 @@ def test_flow_definition_preserves_diagnostics_loaded_from_contract():
"name": "LoadedDiagnosticsFlow",
"methods": {
"decision": {
"do": {"ref": "loaded_flows:LoadedDiagnosticsFlow.decision"},
"router": True,
"emit": ["continue"],
}
@@ -693,6 +667,7 @@ def test_router_start_false_without_listen_reports_missing_trigger():
"name": "LoadedFlow",
"methods": {
"decision": {
"do": {"ref": "loaded_flows:LoadedFlow.decision"},
"router": True,
"start": False,
"emit": ["continue"],
@@ -771,8 +746,14 @@ def test_static_string_listener_is_allowed_by_contract():
"schema": "crewai.flow/v1",
"name": "TypoFlow",
"methods": {
"begin": {"start": True},
"handle": {"listen": "begni"},
"begin": {
"do": {"ref": "loaded_flows:TypoFlow.begin"},
"start": True,
},
"handle": {
"do": {"ref": "loaded_flows:TypoFlow.handle"},
"listen": "begni",
},
},
}
)
@@ -785,8 +766,15 @@ def test_start_false_not_classified_as_start_method():
"schema": "crewai.flow/v1",
"name": "ExplicitNonStartFlow",
"methods": {
"begin": {"start": True},
"handle": {"start": False, "listen": "begin"},
"begin": {
"do": {"ref": "loaded_flows:ExplicitNonStartFlow.begin"},
"start": True,
},
"handle": {
"do": {"ref": "loaded_flows:ExplicitNonStartFlow.handle"},
"start": False,
"listen": "begin",
},
},
}
)
@@ -813,7 +801,7 @@ def test_start_false_not_classified_as_start_method():
assert viz_structure["nodes"]["handle"]["type"] != "start"
def test_flow_definition_cache_is_not_inherited_by_subclasses():
def test_flow_definition_cache_is_not_reused_by_subclasses():
class ParentFlow(Flow):
@start()
def begin(self):
@@ -831,7 +819,7 @@ def test_flow_definition_cache_is_not_inherited_by_subclasses():
assert parent_definition.name == "ParentFlow"
assert child_definition.name == "ChildFlow"
assert child_definition is not parent_definition
assert set(child_definition.methods) == {"begin", "child_step"}
assert set(child_definition.methods) == {"child_step"}
def test_flow_definition_logs_diagnostics_when_loaded_from_contract(caplog):
@@ -843,6 +831,7 @@ def test_flow_definition_logs_diagnostics_when_loaded_from_contract(caplog):
"name": "LoadedFlow",
"methods": {
"decision": {
"do": {"ref": "loaded_flows:LoadedFlow.decision"},
"router": True,
"emit": ["continue"],
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
"""Tests for the pluggable flow persistence factory seam.
We verify our own logic: that ``default_flow_persistence`` returns the
registered factory's result, and that it falls back to the built-in SQLite
persistence when no factory is registered.
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import BaseModel
import crewai.flow.persistence.factory as factory
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.persistence.decorators import persist
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
@pytest.fixture(autouse=True)
def reset_factory():
"""Reset the factory around each test without clobbering preexisting state."""
original = factory._factory
factory.set_flow_persistence_factory(None)
yield
factory.set_flow_persistence_factory(original)
def test_default_uses_registered_factory():
sentinel = SQLiteFlowPersistence()
factory.set_flow_persistence_factory(lambda: sentinel)
assert factory.default_flow_persistence() is sentinel
def test_default_falls_back_to_sqlite():
assert isinstance(factory.default_flow_persistence(), SQLiteFlowPersistence)
def test_persist_decorator_honors_falsy_persistence():
# @persist with an explicit but falsy FlowPersistence must keep it, not
# replace it with the default via a truthiness check.
class _FalsyPersistence(FlowPersistence):
def __bool__(self) -> bool:
return False
def init_db(self) -> None:
pass
def save_state(
self,
flow_uuid: str,
method_name: str,
state_data: dict[str, Any] | BaseModel,
) -> None:
pass
def load_state(self, flow_uuid: str) -> dict[str, Any] | None:
return None
falsy = _FalsyPersistence()
@persist(persistence=falsy)
class _DummyFlow:
pass
assert _DummyFlow.__flow_persistence_config__.persistence is falsy

View File

@@ -0,0 +1,511 @@
"""Tests for flow-level token usage aggregation
``flow.usage_metrics`` listens to ``LLMCallCompletedEvent`` for the duration
of ``kickoff_async`` so it covers every LLM call inside the flow — crew-led,
tool-led, AND bare ``LLM.call(...)`` from a flow method. We exercise the
aggregator end-to-end through the real event bus with fabricated events and
explicit contextvar control; no live LLM provider is required.
"""
from __future__ import annotations
import contextvars
import os
import tempfile
from typing import Any, Callable
from uuid import uuid4
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.llm_events import LLMCallCompletedEvent, LLMCallType
from crewai.flow.async_feedback.types import PendingFeedbackContext
from crewai.flow.flow import Flow, listen, start
from crewai.flow.flow_context import current_flow_id
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
from crewai.flow.runtime import _usage_dict_to_metrics
from crewai.types.usage_metrics import UsageMetrics
def _emit_llm_call(
*,
flow_id: str | None,
prompt_tokens: int = 0,
completion_tokens: int = 0,
cached_prompt_tokens: int = 0,
reasoning_tokens: int = 0,
cache_creation_tokens: int = 0,
) -> None:
"""Emit one fake ``LLMCallCompletedEvent`` with ``current_flow_id`` pinned
to ``flow_id``.
Runs in a freshly-copied context so the value the bus snapshots at emit
time is exactly ``flow_id`` — independent of the calling thread's outer
context. Mirrors how the real ``LLM.call`` emits events at runtime.
"""
usage: dict[str, Any] = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
for key, value in (
("cached_prompt_tokens", cached_prompt_tokens),
("reasoning_tokens", reasoning_tokens),
("cache_creation_tokens", cache_creation_tokens),
):
if value:
usage[key] = value
event = LLMCallCompletedEvent(
call_id=str(uuid4()),
model="gpt-4o-mini",
response="ok",
call_type=LLMCallType.LLM_CALL,
usage=usage,
)
ctx = contextvars.copy_context()
def _emit() -> None:
current_flow_id.set(flow_id)
future = crewai_event_bus.emit(object(), event)
if future is not None:
future.result(timeout=5.0)
ctx.run(_emit)
class _ScriptedFlow(Flow):
"""A Flow whose ``@start`` delegates to a per-instance ``_script`` closure.
Each test attaches a script with ``flow._script = lambda f: ...`` so we
don't redefine a Flow subclass for every scenario.
"""
@start()
def run(self) -> None:
script: Callable[[Flow], None] = getattr(self, "_script", lambda _f: None)
script(self)
def _run(script: Callable[[Flow], None] = lambda _f: None) -> Flow:
"""Build a ``_ScriptedFlow``, attach ``script``, kickoff. Returns the flow."""
flow = _ScriptedFlow()
flow._script = script
flow.kickoff()
return flow
class TestUsageDictToMetrics:
"""Unit tests for the dict-to-UsageMetrics normalizer."""
@pytest.mark.parametrize(
"usage, expected",
[
(None, None),
({}, None),
(
{"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
UsageMetrics(
prompt_tokens=10,
completion_tokens=20,
total_tokens=30,
successful_requests=1,
),
),
# total_tokens missing → derived from prompt + completion
(
{"prompt_tokens": 4, "completion_tokens": 6},
UsageMetrics(
prompt_tokens=4,
completion_tokens=6,
total_tokens=10,
successful_requests=1,
),
),
# Extended provider-specific keys flow through normalization
(
{
"prompt_tokens": 100,
"completion_tokens": 80,
"total_tokens": 180,
"cached_prompt_tokens": 40,
"reasoning_tokens": 25,
"cache_creation_tokens": 10,
},
UsageMetrics(
prompt_tokens=100,
completion_tokens=80,
total_tokens=180,
cached_prompt_tokens=40,
reasoning_tokens=25,
cache_creation_tokens=10,
successful_requests=1,
),
),
# Garbage / non-int values coerce to 0 instead of crashing
(
{"prompt_tokens": "n/a", "completion_tokens": None, "total_tokens": 7},
UsageMetrics(
prompt_tokens=0,
completion_tokens=0,
total_tokens=0,
successful_requests=1,
),
),
# Native Anthropic provider emits input_tokens/output_tokens
(
{"input_tokens": 12, "output_tokens": 8},
UsageMetrics(
prompt_tokens=12,
completion_tokens=8,
total_tokens=20,
successful_requests=1,
),
),
# Native Gemini provider emits prompt_token_count/candidates_token_count
(
{
"prompt_token_count": 30,
"candidates_token_count": 20,
"reasoning_tokens": 5,
},
UsageMetrics(
prompt_tokens=30,
completion_tokens=20,
total_tokens=50,
reasoning_tokens=5,
successful_requests=1,
),
),
# OpenAI nests cached_tokens under prompt_tokens_details
(
{
"prompt_tokens": 100,
"completion_tokens": 50,
"prompt_tokens_details": {"cached_tokens": 30},
},
UsageMetrics(
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
cached_prompt_tokens=30,
successful_requests=1,
),
),
],
ids=[
"none",
"empty",
"all_keys",
"no_total",
"extended_keys",
"garbage",
"anthropic_aliases",
"gemini_aliases",
"openai_nested_cached",
],
)
def test_normalization(
self, usage: dict[str, Any] | None, expected: UsageMetrics | None
) -> None:
assert _usage_dict_to_metrics(usage) == expected
class TestFlowUsageAggregation:
"""End-to-end tests driving the listener through the real event bus."""
def test_sums_every_llm_call_in_the_flow(self) -> None:
"""Multiple LLM calls — including bare ``LLM.call(...)`` made outside
any crew — accumulate; ``successful_requests`` tracks the call count."""
def script(flow: Flow) -> None:
_emit_llm_call(flow_id=flow._flow_match_id, prompt_tokens=300, completion_tokens=300)
_emit_llm_call(flow_id=flow._flow_match_id, prompt_tokens=200, completion_tokens=100)
_emit_llm_call(flow_id=flow._flow_match_id, prompt_tokens=20, completion_tokens=20)
flow = _run(script)
assert flow.usage_metrics.total_tokens == 940
assert flow.usage_metrics.prompt_tokens == 520
assert flow.usage_metrics.completion_tokens == 420
assert flow.usage_metrics.successful_requests == 3
def test_returns_zero_when_no_calls_happen(self) -> None:
flow = _run()
assert flow.usage_metrics == UsageMetrics()
def test_ignores_events_from_other_flows(self) -> None:
"""Concurrent flow runs share the singleton bus, so the listener must
scope itself to its own flow via the contextvar match."""
def script(flow: Flow) -> None:
_emit_llm_call(flow_id=flow._flow_match_id, prompt_tokens=50, completion_tokens=50)
_emit_llm_call(flow_id="some-other-flow", prompt_tokens=49_000, completion_tokens=50_999)
flow = _run(script)
assert flow.usage_metrics.total_tokens == 100
assert flow.usage_metrics.successful_requests == 1
def test_resets_between_kickoffs(self) -> None:
flow = _ScriptedFlow()
flow._script = lambda f: _emit_llm_call(
flow_id=f._flow_match_id, prompt_tokens=250, completion_tokens=250
)
flow.kickoff()
flow.kickoff()
assert flow.usage_metrics.total_tokens == 500
assert flow.usage_metrics.successful_requests == 1
def test_usage_metrics_returns_independent_copy(self) -> None:
"""``usage_metrics`` must return a copy, not the internal instance —
otherwise callers can clobber the in-flight accumulator."""
flow = _run(
lambda f: _emit_llm_call(
flow_id=f._flow_match_id, prompt_tokens=50, completion_tokens=50
)
)
snapshot = flow.usage_metrics
snapshot.total_tokens = 999_999
assert flow.usage_metrics.total_tokens == 100
def test_handler_is_unregistered_after_kickoff(self) -> None:
"""Long-lived workers (Celery, devkit) must not leak one handler per
kickoff on the singleton bus, on either the success or failure path."""
def handler_count() -> int:
return len(
crewai_event_bus._sync_handlers.get(LLMCallCompletedEvent, frozenset())
)
before = handler_count()
flow = _ScriptedFlow()
flow._script = lambda f: _emit_llm_call(
flow_id=f._flow_match_id, prompt_tokens=5, completion_tokens=5
)
for _ in range(3):
flow.kickoff()
assert handler_count() == before
def boom(_f: Flow) -> None:
raise RuntimeError("boom")
failing = _ScriptedFlow()
failing._script = boom
with pytest.raises(RuntimeError, match="boom"):
failing.kickoff()
assert handler_count() == before
def test_kickoff_flushes_event_bus_before_returning(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""`kickoff_async` must drain pending LLMCallCompletedEvent handlers
before detaching the listener — otherwise late handlers landing on
the threadpool would be lost on short flows. Mirrors the flush
``Crew.kickoff()`` performs before reporting ``token_usage``."""
flush_calls: list[None] = []
original_flush = crewai_event_bus.flush
def tracked_flush(*args: Any, **kwargs: Any) -> bool:
flush_calls.append(None)
return original_flush(*args, **kwargs)
monkeypatch.setattr(crewai_event_bus, "flush", tracked_flush)
flow = _ScriptedFlow()
flow._script = lambda f: _emit_llm_call(
flow_id=f._flow_match_id, prompt_tokens=3, completion_tokens=4
)
flow.kickoff()
assert flush_calls, "kickoff did not flush the event bus before returning"
assert flow.usage_metrics.total_tokens == 7
def test_stale_handler_from_prior_kickoff_does_not_contaminate(self) -> None:
"""A handler still queued from a prior kickoff must not write into
a later kickoff's accumulator. The handler's closure captures its
own accumulator object, so any late writes land on an orphaned
instance and the live ``usage_metrics`` is unaffected."""
captured: dict[str, Any] = {}
def script(flow: Flow) -> None:
_emit_llm_call(flow_id=flow._flow_match_id, prompt_tokens=10, completion_tokens=10)
captured["handler"] = flow._usage_aggregation_handler
captured["match_id"] = flow._flow_match_id
flow = _run(script)
assert flow.usage_metrics.total_tokens == 20
flow._script = lambda f: None
flow.kickoff()
assert flow.usage_metrics.total_tokens == 0
stale_handler = captured["handler"]
assert stale_handler is not None
stale_event = LLMCallCompletedEvent(
call_id=str(uuid4()),
model="gpt-4o-mini",
response="ok",
call_type=LLMCallType.LLM_CALL,
usage={"prompt_tokens": 999, "completion_tokens": 999, "total_tokens": 1998},
)
ctx = contextvars.copy_context()
ctx.run(lambda: (current_flow_id.set(captured["match_id"]), stale_handler(object(), stale_event)))
assert flow.usage_metrics.total_tokens == 0
def test_pause_detaches_listener_and_does_not_leak(self) -> None:
"""When ``kickoff_async`` pauses for human feedback, the listener
must be detached from the singleton bus to avoid leaking handlers
across abandoned paused instances. Pre-pause LLM events still
count because the bus snapshots handlers at emit time. Late
events emitted after the pause returns do not count for this
instance — resume paths re-attach a fresh listener."""
from crewai.flow.async_feedback.types import HumanFeedbackPending
captured: dict[str, Any] = {}
class _PausingFlow(Flow):
@start()
def begin(self) -> None:
_emit_llm_call(
flow_id=self._flow_match_id,
prompt_tokens=10,
completion_tokens=20,
)
captured["pre_pause_total"] = self.usage_metrics.total_tokens
raise HumanFeedbackPending(
context=PendingFeedbackContext(
flow_id=self.flow_id,
flow_class="_PausingFlow",
method_name="begin",
method_output="content",
message="Review:",
)
)
with tempfile.TemporaryDirectory() as tmpdir:
persistence = SQLiteFlowPersistence(os.path.join(tmpdir, "f.db"))
flow = _PausingFlow(persistence=persistence)
result = flow.kickoff()
assert isinstance(result, HumanFeedbackPending)
assert captured["pre_pause_total"] == 30
assert flow._usage_aggregation_handler is None
# A late event emitted after the pause does not reach the
# detached listener, so the running total is unchanged.
_emit_llm_call(
flow_id=flow._flow_match_id,
prompt_tokens=2,
completion_tokens=3,
)
assert flow.usage_metrics.total_tokens == 30
def test_aggregates_resume_after_from_pending(self) -> None:
"""A flow restored via ``from_pending`` is a fresh instance with no
``_flow_match_id``; without seeding it, the listener attached in
``resume_async`` either ignores its own LLM calls or absorbs unrelated
ones. ``from_pending`` must seed the match id so the resume-phase
aggregator counts our own calls and only our own calls."""
class _ResumeFlow(Flow):
@start()
def begin(self) -> str:
return "content"
@listen(begin)
def on_begin(self, _feedback: Any) -> str:
_emit_llm_call(
flow_id=self._flow_match_id,
prompt_tokens=100,
completion_tokens=50,
)
_emit_llm_call(
flow_id="some-other-flow",
prompt_tokens=9_999,
completion_tokens=9_999,
)
return "done"
with tempfile.TemporaryDirectory() as tmpdir:
persistence = SQLiteFlowPersistence(os.path.join(tmpdir, "f.db"))
flow_id = "usage-resume-test"
persistence.save_pending_feedback(
flow_uuid=flow_id,
context=PendingFeedbackContext(
flow_id=flow_id,
flow_class="_ResumeFlow",
method_name="begin",
method_output="content",
message="Review:",
),
state_data={"id": flow_id},
)
flow = _ResumeFlow.from_pending(flow_id, persistence)
assert flow._flow_match_id == flow.flow_id
flow.resume("ok")
assert flow.usage_metrics.total_tokens == 150
assert flow.usage_metrics.prompt_tokens == 100
assert flow.usage_metrics.completion_tokens == 50
assert flow.usage_metrics.successful_requests == 1
def test_resume_aggregates_under_foreign_flow_context(self) -> None:
"""Resume must override an already-set ``current_flow_id`` so its
own LLM events match the listener's filter even when invoked from
inside another flow's active context."""
class _ResumeFlow(Flow):
@start()
def begin(self) -> str:
return "content"
@listen(begin)
def on_begin(self, _feedback: Any) -> str:
_emit_llm_call(
flow_id=self._flow_match_id,
prompt_tokens=42,
completion_tokens=8,
)
return "done"
with tempfile.TemporaryDirectory() as tmpdir:
persistence = SQLiteFlowPersistence(os.path.join(tmpdir, "f.db"))
flow_id = "resume-foreign-context"
persistence.save_pending_feedback(
flow_uuid=flow_id,
context=PendingFeedbackContext(
flow_id=flow_id,
flow_class="_ResumeFlow",
method_name="begin",
method_output="content",
message="Review:",
),
state_data={"id": flow_id},
)
foreign_token = current_flow_id.set("some-parent-flow")
try:
flow = _ResumeFlow.from_pending(flow_id, persistence)
flow.resume("ok")
finally:
current_flow_id.reset(foreign_token)
assert flow.usage_metrics.total_tokens == 50
assert flow.usage_metrics.successful_requests == 1

View File

@@ -77,12 +77,22 @@ class ComplexFlow(Flow):
return "complete"
def _attach_flow_definition(flow_class: type[Flow], methods: dict[str, object]) -> None:
def _attach_flow_definition(
flow_class: type[Flow], methods: dict[str, dict[str, object]]
) -> None:
flow_class._flow_definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": flow_class.__name__,
"methods": methods,
"methods": {
name: {
"do": {
"ref": f"{flow_class.__module__}:{flow_class.__name__}.{name}"
},
**spec,
}
for name, spec in methods.items()
},
}
)
@@ -125,13 +135,20 @@ def test_build_flow_structure_from_flow_definition():
"schema": "crewai.flow/v1",
"name": "DefinedFlow",
"methods": {
"begin": {"start": True},
"begin": {
"do": {"ref": "defined_flows:DefinedFlow.begin"},
"start": True,
},
"decide": {
"do": {"ref": "defined_flows:DefinedFlow.decide"},
"listen": "begin",
"router": True,
"emit": ["done"],
},
"finish": {"listen": "done"},
"finish": {
"do": {"ref": "defined_flows:DefinedFlow.finish"},
"listen": "done",
},
},
}
)

View File

@@ -78,8 +78,9 @@ class TestHumanFeedbackValidation:
return "output"
assert hasattr(test_method, "__human_feedback_config__")
assert test_method.__is_router__ is True
assert test_method.__router_emit__ == ["approve", "reject"]
assert test_method.__human_feedback_config__.emit == ["approve", "reject"]
assert not hasattr(test_method, "__is_router__")
assert not hasattr(test_method, "__router_emit__")
def test_valid_configuration_without_routing(self):
"""Test that valid configuration without routing doesn't raise."""
@@ -89,10 +90,10 @@ class TestHumanFeedbackValidation:
return "output"
assert hasattr(test_method, "__human_feedback_config__")
assert not hasattr(test_method, "__is_router__") or not test_method.__is_router__
assert not hasattr(test_method, "__is_router__")
def test_persist_preserves_human_feedback_llm_attribute(self):
"""Test @persist preserves the live LLM stashed by @human_feedback."""
def test_persist_preserves_human_feedback_config(self):
"""Test @persist preserves the config stamped by @human_feedback."""
llm = object()
@persist()
@@ -104,8 +105,8 @@ class TestHumanFeedbackValidation:
def test_method(self):
return "output"
assert hasattr(test_method, "_human_feedback_llm")
assert test_method._human_feedback_llm is llm
assert hasattr(test_method, "__human_feedback_config__")
assert test_method.__human_feedback_config__.llm is llm
class TestHumanFeedbackConfig:
@@ -173,10 +174,12 @@ class TestDecoratorAttributePreservation:
flow = TestFlow()
method = flow._methods.get("my_start_method")
assert method is not None
assert hasattr(method, "__is_start_method__") or "my_start_method" in flow._start_methods
fragment = getattr(method, "__flow_method_definition__", None)
assert fragment is not None
assert fragment.start is True
def test_preserves_listen_method_attributes(self):
"""Test that @human_feedback preserves @listen decorator attributes."""
def test_preserves_listen_method_definition(self):
"""Test that @human_feedback preserves the @listen method definition."""
class TestFlow(Flow):
@start()
@@ -189,12 +192,14 @@ class TestDecoratorAttributePreservation:
return "review output"
flow = TestFlow()
assert "review" in flow._listeners or any(
"review" in str(v) for v in flow._listeners.values()
)
method = flow._methods.get("review")
assert method is not None
fragment = getattr(method, "__flow_method_definition__", None)
assert fragment is not None
assert fragment.listen == "begin"
def test_sets_router_attributes_when_emit_specified(self):
"""Test that router attributes are set when emit is specified."""
def test_emit_is_stored_on_human_feedback_config(self):
"""Test that emit outcomes are stored on human feedback config."""
@human_feedback(
message="Review:",
@@ -204,8 +209,12 @@ class TestDecoratorAttributePreservation:
def review_method(self):
return "output"
assert review_method.__is_router__ is True
assert review_method.__router_emit__ == ["approved", "rejected"]
assert review_method.__human_feedback_config__.emit == [
"approved",
"rejected",
]
assert not hasattr(review_method, "__is_router__")
assert not hasattr(review_method, "__router_emit__")
class TestAsyncSupport:
@@ -472,7 +481,7 @@ class TestHumanFeedbackLearn:
with patch.object(
flow, "_request_human_feedback", return_value="looks good"
):
flow.produce()
flow.kickoff()
# memory.recall and memory.remember_many should NOT be called
flow.memory.recall.assert_not_called()
@@ -507,7 +516,7 @@ class TestHumanFeedbackLearn:
)
MockLLM.return_value = mock_llm
flow.produce()
flow.kickoff()
# remember_many should be called with the distilled lesson
flow.memory.remember_many.assert_called_once()
@@ -542,7 +551,7 @@ class TestHumanFeedbackLearn:
captured_output = {}
def capture_feedback(message, output, metadata=None, emit=None):
def capture_feedback(message, output, metadata=None, emit=None, method_name=""):
captured_output["shown_to_human"] = output
return "approved"
@@ -561,7 +570,7 @@ class TestHumanFeedbackLearn:
]
MockLLM.return_value = mock_llm
flow.produce()
flow.kickoff()
assert captured_output["shown_to_human"] == "draft with citations added"
# recall was called to find past lessons
@@ -583,7 +592,7 @@ class TestHumanFeedbackLearn:
with patch.object(
flow, "_request_human_feedback", return_value=""
):
flow.produce()
flow.kickoff()
flow.memory.remember_many.assert_not_called()
@@ -622,7 +631,7 @@ class TestHumanFeedbackLearn:
captured: dict[str, Any] = {}
def capture_feedback(message, output, metadata=None, emit=None):
def capture_feedback(message, output, metadata=None, emit=None, method_name=""):
captured["shown_to_human"] = output
return ""
@@ -636,7 +645,7 @@ class TestHumanFeedbackLearn:
mock_llm.call.side_effect = RuntimeError("simulated pre-review failure")
MockLLM.return_value = mock_llm
flow.produce()
flow.kickoff()
assert captured["shown_to_human"] == "raw draft"
assert any(
@@ -681,7 +690,7 @@ class TestHumanFeedbackLearn:
MockLLM.return_value = mock_llm
with pytest.raises(RuntimeError, match="simulated pre-review failure"):
flow.produce()
flow.kickoff()
def test_distillation_failure_logs_and_does_not_block_flow(self, caplog):
"""Distillation LLM failure logs a warning but does not break the flow."""
@@ -708,7 +717,7 @@ class TestHumanFeedbackLearn:
mock_llm.call.side_effect = RuntimeError("simulated distill failure")
MockLLM.return_value = mock_llm
flow.produce() # must not raise
flow.kickoff() # must not raise
flow.memory.remember_many.assert_not_called()
assert any(
@@ -851,9 +860,9 @@ class TestHumanFeedbackFinalOutputPreservation:
):
flow.kickoff()
# _method_outputs should contain the real output
assert len(flow._method_outputs) == 1
assert flow._method_outputs[0] == {"data": "real output"}
# method_outputs should contain the real output
assert flow.method_outputs == [{"data": "real output"}]
assert flow._method_outputs[0]["method"] == "generate"
@patch("builtins.input", return_value="looks good")
@patch("builtins.print")

View File

@@ -778,77 +778,11 @@ class TestEdgeCases:
class TestLLMConfigPreservation:
"""Tests that LLM config is preserved through @human_feedback serialization.
PR #4970 introduced _human_feedback_llm stashing so the live LLM object survives
decorator wrapping for same-process resume. The serialization path
(_serialize_llm_for_context / _deserialize_llm_from_context) preserves
config for cross-process resume.
The flow definition keeps the live LLM object for same-process execution.
The serialization path (_serialize_llm_for_context /
_deserialize_llm_from_context) preserves config for cross-process resume.
"""
def test_human_feedback_llm_stashed_on_wrapper_with_llm_instance(self):
"""Test that passing an LLM instance stashes it on the wrapper as _human_feedback_llm."""
from crewai.llm import LLM
llm_instance = LLM(model="gpt-4o-mini", temperature=0.42)
class ConfigFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm=llm_instance,
)
def review(self):
return "content"
method = ConfigFlow.review
assert hasattr(method, "_human_feedback_llm"), "_human_feedback_llm not found on wrapper"
assert method._human_feedback_llm is llm_instance, "_human_feedback_llm is not the same object"
def test_human_feedback_llm_preserved_on_listen_method(self):
"""Test that _human_feedback_llm is preserved when @human_feedback is on a @listen method."""
from crewai.llm import LLM
llm_instance = LLM(model="gpt-4o-mini", temperature=0.7)
class ListenConfigFlow(Flow):
@start()
def generate(self):
return "draft"
@listen("generate")
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm=llm_instance,
)
def review(self):
return "content"
method = ListenConfigFlow.review
assert hasattr(method, "_human_feedback_llm")
assert method._human_feedback_llm is llm_instance
def test_human_feedback_llm_accessible_on_instance(self):
"""Test that _human_feedback_llm survives Flow instantiation (bound method access)."""
from crewai.llm import LLM
llm_instance = LLM(model="gpt-4o-mini", temperature=0.42)
class InstanceFlow(Flow):
@start()
@human_feedback(
message="Review:",
emit=["approved", "rejected"],
llm=llm_instance,
)
def review(self):
return "content"
flow = InstanceFlow()
instance_method = flow.review
assert hasattr(instance_method, "_human_feedback_llm")
assert instance_method._human_feedback_llm is llm_instance
def test_serialize_llm_preserves_config_fields(self):
"""Test that _serialize_llm_for_context captures temperature, base_url, etc."""
from crewai.flow.human_feedback import _serialize_llm_for_context

View File

@@ -838,6 +838,74 @@ def test_flow_method_execution_finished_includes_serialized_state():
assert final_output == "final_result"
def test_suppress_flow_events_silences_method_lifecycle_events():
"""``suppress_flow_events=True`` emits no MethodExecution* events on the
bus (used by infrastructure flows like AgentExecutor so their control-flow
methods don't pollute traces), while default flows still emit them."""
captured: list[tuple[str, str]] = []
class SuppressedFlow(Flow):
suppress_flow_events: bool = True
@start()
def begin(self):
return "started"
@listen("begin")
def process(self):
return "done"
class ControlFlow(Flow):
@start()
def begin(self):
return "started"
@listen("begin")
def process(self):
return "done"
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionStartedEvent)
def _on_started(source, event):
captured.append(("started", type(source).__name__))
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def _on_finished(source, event):
captured.append(("finished", type(source).__name__))
SuppressedFlow().kickoff()
wait_for_event_handlers()
assert [e for e in captured if e[1] == "SuppressedFlow"] == [], (
"suppress_flow_events=True must emit no MethodExecution* events"
)
captured.clear()
ControlFlow().kickoff()
wait_for_event_handlers()
control = [e for e in captured if e[1] == "ControlFlow"]
assert ("started", "ControlFlow") in control
assert ("finished", "ControlFlow") in control
def test_infrastructure_flows_suppress_flow_events_by_default():
"""Pin the infra flows that must stay silent in traces.
The gating in ``_execute_method`` only helps if these flows actually set
``suppress_flow_events=True``; without this guard, removing the flag from
AgentExecutor would silently bring back the verbose per-method trace spans.
"""
from crewai.experimental.agent_executor import AgentExecutor
from crewai.memory.encoding_flow import EncodingFlow
from crewai.memory.recall_flow import RecallFlow
assert AgentExecutor.model_fields["suppress_flow_events"].default is True
for flow_cls in (EncodingFlow, RecallFlow):
flow = flow_cls(storage=None, llm=None, embedder=None)
assert flow.suppress_flow_events is True
@pytest.mark.vcr()
def test_llm_emits_call_started_event():
started_events: list[LLMCallStartedEvent] = []

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.7a2"
__version__ = "1.14.7"