diff --git a/docs/edge/en/index.mdx b/docs/edge/en/index.mdx index ff6466f27..d091196e5 100644 --- a/docs/edge/en/index.mdx +++ b/docs/edge/en/index.mdx @@ -28,6 +28,60 @@ mode: "wide"
Get started + View changelog API Reference
diff --git a/docs/edge/en/installation.mdx b/docs/edge/en/installation.mdx index bd277790a..66f80d248 100644 --- a/docs/edge/en/installation.mdx +++ b/docs/edge/en/installation.mdx @@ -9,7 +9,60 @@ mode: "wide" Install our coding agent skills (Claude Code, Codex, ...) to quickly get your coding agents up and running with CrewAI. -You can install it with `npx skills add crewaiinc/skills` + diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml index e2e50af45..d49abe56a 100644 --- a/lib/cli/pyproject.toml +++ b/lib/cli/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a", + "crewai-core==1.14.8a1", "click>=8.1.7,<9", "pydantic>=2.11.9,<2.13", "pydantic-settings~=2.10.1", diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py index 4e3686826..cb6f48866 100644 --- a/lib/cli/src/crewai_cli/__init__.py +++ b/lib/cli/src/crewai_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a" +__version__ = "1.14.8a1" diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index cf84a7f1b..1df9f0310 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -89,13 +89,16 @@ description = "{name} using crewAI" authors = [{{ name = "Your Name", email = "you@example.com" }}] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]>=1.14.7" + "crewai[tools]==1.14.8a1" ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build.targets.wheel] +only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"] + [tool.crewai] type = "crew" """ diff --git a/lib/cli/src/crewai_cli/crew_run_tui.py b/lib/cli/src/crewai_cli/crew_run_tui.py index ce60c2e93..9b3930350 100644 --- a/lib/cli/src/crewai_cli/crew_run_tui.py +++ b/lib/cli/src/crewai_cli/crew_run_tui.py @@ -34,6 +34,25 @@ _C_MUTED = "#666666" # dimmer than _C_DIM for past timeline _STEP_NUMBER_RE = re.compile(r"\bstep\s+(\d+)\b", re.IGNORECASE) _REFINEMENT_RE = re.compile(r"^\s*step\s+(\d+)\s*:\s*(.+)\s*$", re.IGNORECASE) _INTERNAL_TOOL_NAMES = {"create_reasoning_plan"} +_LOG_ARGS_TEXT_LIMIT = 3_000 +_LOG_RESULT_TEXT_LIMIT = 5_000 +_LOG_TRUNCATION_SUFFIX = "... [truncated]" +# Background memory saves can emit their start event just after kickoff returns. +_MEMORY_SAVE_DRAIN_GRACE_SECONDS = 2.0 + + +def _is_save_to_memory_tool(tool_name: str | None) -> bool: + return (tool_name or "").replace(" ", "_").lower() == "save_to_memory" + + +def _truncate_log_text(value: Any, limit: int) -> str | None: + if value is None: + return None + text = str(value) + if len(text) <= limit: + return text + suffix = _LOG_TRUNCATION_SUFFIX + return f"{text[: max(0, limit - len(suffix))]}{suffix}" def _enable_tracing_in_dotenv() -> None: @@ -519,6 +538,8 @@ FooterKey .footer-key--key { self._log_expanded: set[int] = set() self._log_scroll_needed: bool = False self._log_line_map: list[tuple[int, int, int]] = [] + self._suppressed_memory_save_event_ids: set[str] = set() + self._memory_save_drain_timer: Any = None self._event_handlers: list[tuple[type, Any]] = [] @@ -633,7 +654,6 @@ FooterKey .footer-key--key { self.call_from_thread(self._on_crew_failed, str(e)) def _on_crew_done(self, output: str | None) -> None: - self._unsubscribe() with self._lock: self._status = "completed" self._final_output = output @@ -649,6 +669,8 @@ FooterKey .footer-key--key { now = time.time() for entry in self._log_entries: if entry["status"] == "running": + if entry["tool_name"] == "memory_save": + continue entry["status"] = "timeout" entry["error"] = "No result received before crew completed" entry["duration"] = now - entry["start_time"] @@ -680,9 +702,9 @@ FooterKey .footer-key--key { self.call_later(self._focus_activity_log) self._tick_timer.stop() self._tick_timer = self.set_interval(1 / 2, self._tick) + self._unsubscribe_if_no_running_memory_save(wait_for_queued=True) def _on_crew_failed(self, error: str) -> None: - self._unsubscribe() with self._lock: self._status = "failed" self._error = error @@ -692,12 +714,16 @@ FooterKey .footer-key--key { now = time.time() for entry in self._log_entries: if entry["status"] == "running": + if entry["tool_name"] == "memory_save": + continue entry["status"] = "error" + entry["error"] = "No result received before crew failed" entry["duration"] = now - entry["start_time"] self._tick() self.call_later(self._focus_activity_log) self._tick_timer.stop() self._tick_timer = self.set_interval(1 / 2, self._tick) + self._unsubscribe_if_no_running_memory_save(wait_for_queued=True) # ── Actions ───────────────────────────────────────────── @@ -1514,6 +1540,53 @@ FooterKey .footer-key--key { pass self._event_handlers.clear() + def _has_running_memory_save_locked(self) -> bool: + return any( + entry["tool_name"] == "memory_save" and entry["status"] == "running" + for entry in self._log_entries + ) + + def _on_memory_save_drain_elapsed(self) -> None: + self._memory_save_drain_timer = None + self._unsubscribe_if_no_running_memory_save() + + def _schedule_memory_save_drain_unsubscribe(self) -> bool: + loop = getattr(self, "_loop", None) + if loop is None: + return False + if getattr(self, "_thread_id", None) != threading.get_ident(): + try: + loop.call_soon_threadsafe(self._schedule_memory_save_drain_unsubscribe) + except RuntimeError: + return False + return True + if self._memory_save_drain_timer is not None: + self._memory_save_drain_timer.stop() + self._memory_save_drain_timer = self.set_timer( + _MEMORY_SAVE_DRAIN_GRACE_SECONDS, + self._on_memory_save_drain_elapsed, + name="memory-save-drain", + ) + return True + + def _unsubscribe_if_no_running_memory_save( + self, *, wait_for_queued: bool = False + ) -> None: + with self._lock: + should_unsubscribe = ( + self._status + in { + "completed", + "failed", + } + and not self._has_running_memory_save_locked() + ) + + if should_unsubscribe: + if wait_for_queued and self._schedule_memory_save_drain_unsubscribe(): + return + self._unsubscribe() + def _subscribe(self) -> None: from crewai.events.event_bus import crewai_event_bus from crewai.events.types.crew_events import CrewKickoffStartedEvent @@ -1802,6 +1875,8 @@ FooterKey .footer-key--key { entry["status"] == "running" and entry["tool_name"] != event.tool_name ): + if entry["tool_name"] == "memory_save": + continue entry["status"] = "timeout" entry["error"] = ( "No result received before the next tool started" @@ -1830,6 +1905,7 @@ FooterKey .footer-key--key { "duration": None, "task_idx": self._current_task_idx, "plan_step_number": plan_step_number, + "event_id": event.event_id, } ) self._complete_step("teal", f"⚡ {event.tool_name}…") @@ -1923,8 +1999,178 @@ FooterKey .footer-key--key { MemoryRetrievalCompletedEvent, MemoryRetrievalFailedEvent, MemoryRetrievalStartedEvent, + MemorySaveCompletedEvent, + MemorySaveFailedEvent, + MemorySaveStartedEvent, ) + def is_nested_save_to_memory_event(event: Any) -> bool: + if event.parent_event_id is None: + return False + state = crewai_event_bus.runtime_state + if state is None: + return False + parent_node = state.event_record.nodes.get(event.parent_event_id) + parent_event = getattr(parent_node, "event", None) + return getattr( + parent_event, "type", None + ) == "tool_usage_started" and _is_save_to_memory_tool( + getattr(parent_event, "tool_name", None) + ) + + @crewai_event_bus.on(MemorySaveStartedEvent) + def on_memory_save_started(source: Any, event: MemorySaveStartedEvent) -> None: + with self._lock: + if is_nested_save_to_memory_event(event): + self._suppressed_memory_save_event_ids.add(event.event_id) + return + for entry in reversed(self._log_entries): + if ( + _is_save_to_memory_tool(entry["tool_name"]) + and entry.get("event_id") == event.parent_event_id + ): + self._suppressed_memory_save_event_ids.add(event.event_id) + return + for entry in reversed(self._log_entries): + if ( + entry["tool_name"] == "memory_save" + and entry.get("started_event_id") == event.event_id + ): + entry["args"] = _truncate_log_text( + event.value, _LOG_ARGS_TEXT_LIMIT + ) + return + self._log_entries.append( + { + "tool_name": "memory_save", + "status": "running", + "args": _truncate_log_text(event.value, _LOG_ARGS_TEXT_LIMIT), + "result": None, + "error": None, + "start_time": time.time(), + "duration": None, + "task_idx": self._current_task_idx, + "event_id": event.event_id, + } + ) + + self._register_handler(MemorySaveStartedEvent, on_memory_save_started) + + @crewai_event_bus.on(MemorySaveCompletedEvent) + def on_memory_save_completed( + source: Any, event: MemorySaveCompletedEvent + ) -> None: + with self._lock: + if ( + event.started_event_id in self._suppressed_memory_save_event_ids + or is_nested_save_to_memory_event(event) + ): + if event.started_event_id is not None: + self._suppressed_memory_save_event_ids.discard( + event.started_event_id + ) + else: + for entry in reversed(self._log_entries): + has_started_event_match = ( + event.started_event_id is not None + and ( + entry.get("event_id") == event.started_event_id + or entry.get("started_event_id") + == event.started_event_id + ) + ) + has_running_event_without_id = ( + event.started_event_id is None + and entry["status"] == "running" + ) + if entry["tool_name"] == "memory_save" and ( + has_running_event_without_id or has_started_event_match + ): + entry["status"] = "success" + entry["duration"] = event.save_time_ms / 1000 + entry["result"] = _truncate_log_text( + event.value, _LOG_RESULT_TEXT_LIMIT + ) + entry["error"] = None + entry["started_event_id"] = event.started_event_id + break + else: + self._log_entries.append( + { + "tool_name": "memory_save", + "status": "success", + "args": None, + "result": _truncate_log_text( + event.value, _LOG_RESULT_TEXT_LIMIT + ), + "error": None, + "start_time": time.time(), + "duration": event.save_time_ms / 1000, + "task_idx": self._current_task_idx, + "started_event_id": event.started_event_id, + } + ) + + self._unsubscribe_if_no_running_memory_save(wait_for_queued=True) + + self._register_handler(MemorySaveCompletedEvent, on_memory_save_completed) + + @crewai_event_bus.on(MemorySaveFailedEvent) + def on_memory_save_failed(source: Any, event: MemorySaveFailedEvent) -> None: + with self._lock: + if ( + event.started_event_id in self._suppressed_memory_save_event_ids + or is_nested_save_to_memory_event(event) + ): + if event.started_event_id is not None: + self._suppressed_memory_save_event_ids.discard( + event.started_event_id + ) + else: + for idx, entry in reversed(list(enumerate(self._log_entries))): + has_started_event_match = ( + event.started_event_id is not None + and ( + entry.get("event_id") == event.started_event_id + or entry.get("started_event_id") + == event.started_event_id + ) + ) + has_running_event_without_id = ( + event.started_event_id is None + and entry["status"] == "running" + ) + if entry["tool_name"] == "memory_save" and ( + has_running_event_without_id or has_started_event_match + ): + entry["status"] = "error" + entry["error"] = event.error + entry["duration"] = time.time() - entry["start_time"] + entry["started_event_id"] = event.started_event_id + self._log_expanded.add(idx) + break + else: + self._log_entries.append( + { + "tool_name": "memory_save", + "status": "error", + "args": _truncate_log_text( + event.value, _LOG_ARGS_TEXT_LIMIT + ), + "result": None, + "error": event.error, + "start_time": time.time(), + "duration": 0, + "task_idx": self._current_task_idx, + "started_event_id": event.started_event_id, + } + ) + self._log_expanded.add(len(self._log_entries) - 1) + + self._unsubscribe_if_no_running_memory_save(wait_for_queued=True) + + self._register_handler(MemorySaveFailedEvent, on_memory_save_failed) + @crewai_event_bus.on(MemoryRetrievalStartedEvent) def on_memory_retrieval_started( source: Any, event: MemoryRetrievalStartedEvent diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index cbc14ff86..cbf2445d1 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from contextlib import AbstractContextManager, nullcontext from enum import Enum import os @@ -7,10 +8,9 @@ from pathlib import Path import re import subprocess import sys -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import click -from crewai.project.json_loader import find_crew_json_file from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV from packaging import version @@ -38,6 +38,15 @@ class CrewType(Enum): _INPUT_PLACEHOLDER_RE = re.compile(r"(? Callable[[], Path | None]: + from crewai.project.json_loader import find_crew_json_file as _find_crew_json_file + + return cast("Callable[[], Path | None]", _find_crew_json_file) + + +def _is_missing_crewai_package(exc: ModuleNotFoundError) -> bool: + return bool(exc.name and exc.name.startswith("crewai")) + + +def _full_crewai_install_error() -> click.ClickException: + return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE) + + +def find_crew_json_file() -> Path | None: + try: + return _import_find_crew_json_file()() + except ModuleNotFoundError as exc: + if _is_missing_crewai_package(exc): + raise _full_crewai_install_error() from exc + raise + + def _has_json_crew() -> bool: """Check if this is a JSON-defined crew project. diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml index d947fa4b2..222ad7141 100644 --- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml @@ -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.8a" + "crewai[tools]==1.14.8a1" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml index e63ee69ea..9767c17d9 100644 --- a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml @@ -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.8a" + "crewai[tools]==1.14.8a1" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml index 95d8762c3..c25698a91 100644 --- a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml @@ -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.8a" + "crewai[tools]==1.14.8a1" ] [tool.crewai] diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index bb4bc68be..adfe1757f 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -5,7 +5,10 @@ from pathlib import Path from unittest import mock import pytest +import tomli from click.testing import CliRunner +from packaging.requirements import Requirement +from packaging.version import Version import crewai_cli.create_json_crew as json_crew import crewai_cli.tui_picker as tui_picker from crewai_cli.create_crew import create_crew, create_folder_structure @@ -712,6 +715,14 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch): assert not (tmp_path / "json_crew" / "tests").exists() assert not (tmp_path / "json_crew" / "config.jsonc").exists() + pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text()) + dependency = pyproject["project"]["dependencies"][0] + assert dependency == "crewai[tools]==1.14.8a1" + assert Version("1.14.8a1") in Requirement(dependency).specifier + assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ + "only-include" + ] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"] + crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text() assert ( '"guardrail": "Every factual claim needs context support."' diff --git a/lib/cli/tests/test_crew_run_tui.py b/lib/cli/tests/test_crew_run_tui.py index 7b018107a..969bc5ae2 100644 --- a/lib/cli/tests/test_crew_run_tui.py +++ b/lib/cli/tests/test_crew_run_tui.py @@ -4,6 +4,11 @@ import time import pytest from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.memory_events import ( + MemorySaveCompletedEvent, + MemorySaveFailedEvent, + MemorySaveStartedEvent, +) from crewai.events.types.observation_events import ( GoalAchievedEarlyEvent, PlanRefinementEvent, @@ -21,7 +26,12 @@ from crewai.events.types.tool_usage_events import ( ) from crewai_cli.command import AuthenticationRequiredError from crewai_cli import run_crew -from crewai_cli.crew_run_tui import CrewRunApp +from crewai_cli.crew_run_tui import ( + CrewRunApp, + _LOG_ARGS_TEXT_LIMIT, + _LOG_RESULT_TEXT_LIMIT, + _LOG_TRUNCATION_SUFFIX, +) def _app_with_plan() -> CrewRunApp: @@ -335,6 +345,396 @@ def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None: assert app._current_task_steps == [] +def test_memory_save_events_are_shown_in_activity_log() -> None: + app = _app_with_plan() + app._current_task_idx = 1 + app._subscribe() + try: + _emit_event( + MemorySaveStartedEvent( + value="2 memories (background)", + metadata={}, + source_type="unified_memory", + ) + ) + _emit_event( + MemorySaveCompletedEvent( + value="2 memories saved", + metadata={}, + save_time_ms=123, + source_type="unified_memory", + ) + ) + finally: + app._unsubscribe() + + assert len(app._log_entries) == 1 + assert app._log_entries[0]["tool_name"] == "memory_save" + assert app._log_entries[0]["status"] == "success" + assert app._log_entries[0]["args"] == "2 memories (background)" + assert app._log_entries[0]["result"] == "2 memories saved" + assert app._log_entries[0]["error"] is None + assert app._log_entries[0]["duration"] == 0.123 + assert app._log_entries[0]["task_idx"] == 1 + + +def test_nested_memory_save_event_is_hidden_for_save_to_memory_tool() -> None: + app = _app_with_plan() + app._subscribe() + try: + tool_args = {"contents": ["Fact to remember."]} + _emit_event( + ToolUsageStartedEvent( + tool_name="save_to_memory", + tool_args=tool_args, + ) + ) + _emit_event( + MemorySaveStartedEvent( + value="Fact to remember.", + metadata={}, + source_type="unified_memory", + ) + ) + _emit_event( + MemorySaveCompletedEvent( + value="Fact to remember.", + metadata={}, + save_time_ms=123, + source_type="unified_memory", + ) + ) + now = datetime.now() + _emit_event( + ToolUsageFinishedEvent( + tool_name="save_to_memory", + tool_args=tool_args, + started_at=now, + finished_at=now, + output="Saved to memory.", + ) + ) + finally: + app._unsubscribe() + + assert len(app._log_entries) == 1 + assert app._log_entries[0]["tool_name"] == "save_to_memory" + assert app._log_entries[0]["status"] == "success" + assert app._log_entries[0]["result"] == "Saved to memory." + + +def test_memory_save_failure_is_shown_in_activity_log() -> None: + app = _app_with_plan() + app._subscribe() + try: + _emit_event( + MemorySaveStartedEvent( + value="background save", + metadata={}, + source_type="unified_memory", + ) + ) + _emit_event( + MemorySaveFailedEvent( + value="background save", + metadata={}, + error="embedding connection failed", + source_type="unified_memory", + ) + ) + finally: + app._unsubscribe() + + assert app._log_entries[0]["tool_name"] == "memory_save" + assert app._log_entries[0]["status"] == "error" + assert app._log_entries[0]["error"] == "embedding connection failed" + assert app._log_expanded == {0} + + +def test_memory_save_completion_updates_timed_out_row() -> None: + app = _app_with_plan() + app._subscribe() + try: + _emit_event( + MemorySaveStartedEvent( + value="9 memories (background)", + metadata={}, + source_type="unified_memory", + ) + ) + + app._log_entries[0]["status"] = "timeout" + app._log_entries[0]["error"] = "No result received before crew completed" + app._log_entries[0]["duration"] = 8.3 + + _emit_event( + MemorySaveCompletedEvent( + value="9 memories saved", + metadata={}, + save_time_ms=8300, + source_type="unified_memory", + ) + ) + finally: + app._unsubscribe() + + assert len(app._log_entries) == 1 + assert app._log_entries[0]["tool_name"] == "memory_save" + assert app._log_entries[0]["status"] == "success" + assert app._log_entries[0]["result"] == "9 memories saved" + assert app._log_entries[0]["error"] is None + assert app._log_entries[0]["duration"] == 8.3 + + +def test_memory_save_completion_with_unmatched_id_does_not_update_running_row() -> None: + app = _app_with_plan() + app._subscribe() + try: + _emit_event( + MemorySaveStartedEvent( + value="first background save", + metadata={}, + source_type="unified_memory", + parent_event_id="manual-parent", + ) + ) + _emit_event( + MemorySaveStartedEvent( + value="second background save", + metadata={}, + source_type="unified_memory", + parent_event_id="manual-parent", + ) + ) + + _emit_event( + MemorySaveCompletedEvent( + value="orphan save completed", + metadata={}, + save_time_ms=2800, + source_type="unified_memory", + parent_event_id="manual-parent", + started_event_id="missing-memory-save-start", + ) + ) + finally: + app._unsubscribe() + + assert [entry["status"] for entry in app._log_entries] == [ + "running", + "running", + "success", + ] + assert app._log_entries[0]["args"] == "first background save" + assert app._log_entries[1]["args"] == "second background save" + assert app._log_entries[2]["result"] == "orphan save completed" + assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start" + + +def test_memory_save_failure_with_unmatched_id_does_not_update_running_row() -> None: + app = _app_with_plan() + app._subscribe() + try: + _emit_event( + MemorySaveStartedEvent( + value="first background save", + metadata={}, + source_type="unified_memory", + parent_event_id="manual-parent", + ) + ) + _emit_event( + MemorySaveStartedEvent( + value="second background save", + metadata={}, + source_type="unified_memory", + parent_event_id="manual-parent", + ) + ) + + _emit_event( + MemorySaveFailedEvent( + value="orphan save failed", + metadata={}, + error="embedding connection failed", + source_type="unified_memory", + parent_event_id="manual-parent", + started_event_id="missing-memory-save-start", + ) + ) + finally: + app._unsubscribe() + + assert [entry["status"] for entry in app._log_entries] == [ + "running", + "running", + "error", + ] + assert app._log_entries[0]["args"] == "first background save" + assert app._log_entries[1]["args"] == "second background save" + assert app._log_entries[2]["args"] == "orphan save failed" + assert app._log_entries[2]["error"] == "embedding connection failed" + assert app._log_entries[2]["started_event_id"] == "missing-memory-save-start" + assert app._log_expanded == {2} + + +def test_memory_save_completion_without_id_does_not_update_stale_row() -> None: + app = _app_with_plan() + now = time.time() + app._log_entries = [ + { + "tool_name": "memory_save", + "status": "running", + "args": "current background save", + "result": None, + "error": None, + "start_time": now, + "duration": None, + "task_idx": 1, + }, + { + "tool_name": "memory_save", + "status": "success", + "args": "stale background save", + "result": "stale save completed", + "error": None, + "start_time": now - 10, + "duration": 1.0, + "task_idx": 1, + }, + ] + + app._subscribe() + try: + _emit_event( + MemorySaveCompletedEvent( + value="current save completed", + metadata={}, + save_time_ms=2800, + source_type="unified_memory", + parent_event_id="manual-parent", + ) + ) + finally: + app._unsubscribe() + + assert [entry["status"] for entry in app._log_entries] == [ + "success", + "success", + ] + assert app._log_entries[0]["args"] == "current background save" + assert app._log_entries[0]["result"] == "current save completed" + assert app._log_entries[1]["args"] == "stale background save" + assert app._log_entries[1]["result"] == "stale save completed" + + +def test_memory_save_failure_without_id_does_not_update_stale_row() -> None: + app = _app_with_plan() + now = time.time() + app._log_entries = [ + { + "tool_name": "memory_save", + "status": "running", + "args": "current background save", + "result": None, + "error": None, + "start_time": now, + "duration": None, + "task_idx": 1, + }, + { + "tool_name": "memory_save", + "status": "success", + "args": "stale background save", + "result": "stale save completed", + "error": None, + "start_time": now - 10, + "duration": 1.0, + "task_idx": 1, + }, + ] + + app._subscribe() + try: + _emit_event( + MemorySaveFailedEvent( + value="current save failed", + metadata={}, + error="embedding connection failed", + source_type="unified_memory", + parent_event_id="manual-parent", + ) + ) + finally: + app._unsubscribe() + + assert [entry["status"] for entry in app._log_entries] == ["error", "success"] + assert app._log_entries[0]["args"] == "current background save" + assert app._log_entries[0]["error"] == "embedding connection failed" + assert app._log_entries[1]["args"] == "stale background save" + assert app._log_entries[1]["result"] == "stale save completed" + assert app._log_entries[1]["error"] is None + assert app._log_expanded == {0} + + +def test_memory_save_payloads_are_truncated_in_activity_log() -> None: + app = _app_with_plan() + long_args = "a" * (_LOG_ARGS_TEXT_LIMIT + 10) + long_result = "r" * (_LOG_RESULT_TEXT_LIMIT + 10) + + app._subscribe() + try: + _emit_event( + MemorySaveStartedEvent( + value=long_args, + metadata={}, + source_type="unified_memory", + ) + ) + _emit_event( + MemorySaveCompletedEvent( + value=long_result, + metadata={}, + save_time_ms=8300, + source_type="unified_memory", + ) + ) + finally: + app._unsubscribe() + + assert len(app._log_entries[0]["args"]) == _LOG_ARGS_TEXT_LIMIT + assert app._log_entries[0]["args"].endswith(_LOG_TRUNCATION_SUFFIX) + assert len(app._log_entries[0]["result"]) == _LOG_RESULT_TEXT_LIMIT + assert app._log_entries[0]["result"].endswith(_LOG_TRUNCATION_SUFFIX) + + +def test_starting_next_tool_does_not_timeout_memory_save() -> None: + app = _app_with_plan() + app._subscribe() + try: + _emit_event( + MemorySaveStartedEvent( + value="9 memories (background)", + metadata={}, + source_type="unified_memory", + ) + ) + _emit_event( + ToolUsageStartedEvent( + tool_name="read_website_content", + tool_args={"url": "https://example.com"}, + ) + ) + finally: + app._unsubscribe() + + assert app._log_entries[0]["tool_name"] == "memory_save" + assert app._log_entries[0]["status"] == "running" + assert app._log_entries[0]["error"] is None + assert app._log_entries[1]["tool_name"] == "read_website_content" + assert app._log_entries[1]["status"] == "running" + + def test_tool_failure_does_not_override_successful_plan_step_completion() -> None: app = _app_with_plan() app._subscribe() @@ -480,6 +880,187 @@ async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None: assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"} +@pytest.mark.asyncio +async def test_crew_done_does_not_timeout_memory_save() -> None: + app = _app_with_plan() + + async with app.run_test(size=(100, 40)) as pilot: + app._log_entries = [ + { + "tool_name": "memory_save", + "status": "running", + "args": "9 memories (background)", + "result": None, + "error": None, + "start_time": time.time() - 8, + "duration": None, + "task_idx": 1, + }, + { + "tool_name": "search", + "status": "running", + "args": '{"query": "CrewAI"}', + "result": None, + "error": None, + "start_time": time.time() - 2, + "duration": None, + "task_idx": 1, + }, + ] + + app._on_crew_done("final output") + await pilot.pause() + + assert app._log_entries[0]["status"] == "running" + assert app._log_entries[0]["error"] is None + assert app._log_entries[1]["status"] == "timeout" + assert app._log_entries[1]["error"] == "No result received before crew completed" + + +@pytest.mark.asyncio +async def test_crew_done_keeps_memory_save_subscription_until_completion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05 + ) + app = _app_with_plan() + auto_unsubscribed = False + + async with app.run_test(size=(100, 40)) as pilot: + try: + assert app._event_handlers + started_event = MemorySaveStartedEvent( + value="9 memories (background)", + metadata={}, + source_type="unified_memory", + ) + _emit_event(started_event) + + app._on_crew_done("final output") + await pilot.pause() + + assert app._log_entries[0]["status"] == "running" + assert app._event_handlers + + _emit_event( + MemorySaveCompletedEvent( + value="9 memories saved", + metadata={}, + save_time_ms=8300, + source_type="unified_memory", + started_event_id=started_event.event_id, + ) + ) + await pilot.pause() + + assert app._event_handlers + await pilot.pause(0.08) + auto_unsubscribed = not app._event_handlers + finally: + app._unsubscribe() + + assert app._log_entries[0]["tool_name"] == "memory_save" + assert app._log_entries[0]["status"] == "success" + assert app._log_entries[0]["result"] == "9 memories saved" + assert app._log_entries[0]["error"] is None + assert app._log_entries[0]["duration"] == 8.3 + assert auto_unsubscribed is True + + +@pytest.mark.asyncio +async def test_crew_done_waits_for_queued_memory_save_events( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "crewai_cli.crew_run_tui._MEMORY_SAVE_DRAIN_GRACE_SECONDS", 0.05 + ) + app = _app_with_plan() + auto_unsubscribed = False + + async with app.run_test(size=(100, 40)) as pilot: + try: + assert app._event_handlers + + app._on_crew_done("final output") + + assert app._event_handlers + started_event = MemorySaveStartedEvent( + value="9 memories (background)", + metadata={}, + source_type="unified_memory", + parent_event_id="manual-parent", + ) + _emit_event(started_event) + await pilot.pause() + + assert app._log_entries[0]["tool_name"] == "memory_save" + assert app._log_entries[0]["status"] == "running" + + _emit_event( + MemorySaveCompletedEvent( + value="9 memories saved", + metadata={}, + save_time_ms=8300, + source_type="unified_memory", + parent_event_id="manual-parent", + started_event_id=started_event.event_id, + ) + ) + await pilot.pause() + + assert app._event_handlers + await pilot.pause(0.08) + auto_unsubscribed = not app._event_handlers + finally: + app._unsubscribe() + + assert app._log_entries[0]["tool_name"] == "memory_save" + assert app._log_entries[0]["status"] == "success" + assert app._log_entries[0]["args"] == "9 memories (background)" + assert app._log_entries[0]["result"] == "9 memories saved" + assert app._log_entries[0]["error"] is None + assert app._log_entries[0]["duration"] == 8.3 + assert auto_unsubscribed is True + + +@pytest.mark.asyncio +async def test_crew_failed_does_not_timeout_memory_save() -> None: + app = _app_with_plan() + + async with app.run_test(size=(100, 40)) as pilot: + app._log_entries = [ + { + "tool_name": "memory_save", + "status": "running", + "args": "9 memories (background)", + "result": None, + "error": None, + "start_time": time.time() - 8, + "duration": None, + "task_idx": 1, + }, + { + "tool_name": "search", + "status": "running", + "args": '{"query": "CrewAI"}', + "result": None, + "error": None, + "start_time": time.time() - 2, + "duration": None, + "task_idx": 1, + }, + ] + + app._on_crew_failed("boom") + await pilot.pause() + + assert app._log_entries[0]["status"] == "running" + assert app._log_entries[0]["error"] is None + assert app._log_entries[1]["status"] == "error" + assert app._log_entries[1]["error"] == "No result received before crew failed" + + def test_streamed_step_observation_updates_named_step_only() -> None: app = _app_with_plan() diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index 88d31fa1e..ebb48d72b 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -5,12 +5,33 @@ from pathlib import Path import subprocess import sys +import click import pytest from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV import crewai_cli.run_crew as run_crew_module +def test_missing_crewai_package_shows_full_install_hint(monkeypatch): + def missing_crewai_package(): + raise ModuleNotFoundError("No module named 'crewai'", name="crewai") + + monkeypatch.setattr( + run_crew_module, "_import_find_crew_json_file", missing_crewai_package + ) + + with pytest.raises(click.ClickException) as exc_info: + run_crew_module.find_crew_json_file() + + message = exc_info.value.message + assert "CrewAI CLI is installed without the `crewai` package" in message + assert ( + "uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'" + in message + ) + assert "quotes are required in zsh" in message + + def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch): """crewai run -f must reach JSON crews, not only classic subprocess crews.""" monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True) diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py index 4e3686826..cb6f48866 100644 --- a/lib/crewai-core/src/crewai_core/__init__.py +++ b/lib/crewai-core/src/crewai_core/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a" +__version__ = "1.14.8a1" diff --git a/lib/crewai-files/pyproject.toml b/lib/crewai-files/pyproject.toml index 0302b5900..e4d6cd29e 100644 --- a/lib/crewai-files/pyproject.toml +++ b/lib/crewai-files/pyproject.toml @@ -9,7 +9,7 @@ authors = [ requires-python = ">=3.10, <3.14" dependencies = [ "Pillow~=12.1.1", - "pypdf~=6.10.0", + "pypdf~=6.13.3", "python-magic>=0.4.27", "aiocache~=0.12.3", "aiofiles~=24.1.0", @@ -19,6 +19,8 @@ dependencies = [ [tool.uv] exclude-newer = "3 days" +# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff. +exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" } [build-system] requires = ["hatchling"] diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py index fbb2e6c8b..fdc70ce7d 100644 --- a/lib/crewai-files/src/crewai_files/__init__.py +++ b/lib/crewai-files/src/crewai_files/__init__.py @@ -152,4 +152,4 @@ __all__ = [ "wrap_file_source", ] -__version__ = "1.14.8a" +__version__ = "1.14.8a1" diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 50e80a7a9..111f0a2ec 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14" dependencies = [ "pytube~=15.0.0", "requests>=2.33.0,<3", - "crewai==1.14.8a", + "crewai==1.14.8a1", "tiktoken>=0.8.0,<0.13", "beautifulsoup4~=4.13.4", "python-docx~=1.2.0", diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index a6ba3c303..89761b074 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -330,4 +330,4 @@ __all__ = [ "ZapierActionTools", ] -__version__ = "1.14.8a" +__version__ = "1.14.8a1" diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 1d8608fe4..44fa7104f 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -8,8 +8,8 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a", - "crewai-cli==1.14.8a", + "crewai-core==1.14.8a1", + "crewai-cli==1.14.8a1", # Core Dependencies "pydantic>=2.11.9,<2.13", "openai>=2.30.0,<3", @@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI" [project.optional-dependencies] tools = [ - "crewai-tools==1.14.8a", + "crewai-tools==1.14.8a1", ] embeddings = [ "tiktoken>=0.8.0,<0.13" diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py index 4a9f40a58..e8e8f4faa 100644 --- a/lib/crewai/src/crewai/__init__.py +++ b/lib/crewai/src/crewai/__init__.py @@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None: _suppress_pydantic_deprecation_warnings() -__version__ = "1.14.8a" +__version__ = "1.14.8a1" _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "Memory": ("crewai.memory.unified_memory", "Memory"), diff --git a/lib/crewai/src/crewai/memory/unified_memory.py b/lib/crewai/src/crewai/memory/unified_memory.py index 5a8718eda..dcd5383ce 100644 --- a/lib/crewai/src/crewai/memory/unified_memory.py +++ b/lib/crewai/src/crewai/memory/unified_memory.py @@ -3,7 +3,9 @@ from __future__ import annotations from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import suppress import contextvars +import copy from datetime import datetime import threading import time @@ -53,6 +55,24 @@ def _default_embedder() -> OpenAIEmbeddingFunction: return build_embedder(spec) +def _non_streaming_analysis_llm(llm: Any) -> Any: + """Return an isolated non-streaming LLM for internal memory analysis.""" + if not isinstance(llm, BaseLLM): + return llm + + try: + analysis_llm = copy.copy(llm) + except Exception: + try: + analysis_llm = llm.model_copy(deep=False) + except Exception: + return llm + + with suppress(Exception): + analysis_llm.stream = False + return analysis_llm + + class Memory(BaseModel): """Unified memory: standalone, LLM-analyzed, with intelligent recall flow. @@ -200,7 +220,9 @@ class Memory(BaseModel): query_analysis_threshold=self.query_analysis_threshold, ) - self._llm_instance = None if isinstance(self.llm, str) else self.llm + self._llm_instance = ( + None if isinstance(self.llm, str) else _non_streaming_analysis_llm(self.llm) + ) self._embedder_instance = ( self.embedder if (self.embedder is not None and not isinstance(self.embedder, dict)) diff --git a/lib/crewai/tests/memory/test_unified_memory.py b/lib/crewai/tests/memory/test_unified_memory.py index cbd9cc3ee..8a3d52e7a 100644 --- a/lib/crewai/tests/memory/test_unified_memory.py +++ b/lib/crewai/tests/memory/test_unified_memory.py @@ -19,6 +19,39 @@ from crewai.memory.types import ( ) +def test_memory_analysis_llm_is_isolated_from_streaming_agent_llm( + tmp_path: Path, +) -> None: + """Memory analysis should not share a mutable streaming LLM with the agent UI.""" + from crewai.llms.base_llm import BaseLLM + from crewai.memory.unified_memory import Memory + from crewai.utilities.types import LLMMessage + + class FakeStreamingLLM(BaseLLM): + def call( + self, + messages: str | list[LLMMessage], + tools: list[dict] | None = None, + callbacks: list | None = None, + available_functions: dict | None = None, + from_task: object | None = None, + from_agent: object | None = None, + response_model: type | None = None, + ) -> str: + return "" + + agent_llm = FakeStreamingLLM(model="fake-model", stream=True) + mem = Memory( + storage=str(tmp_path / "db"), + llm=agent_llm, + embedder=lambda texts: [[0.1] for _ in texts], + ) + + assert mem._llm is not agent_llm + assert mem._llm.stream is False + + agent_llm.stream = True + assert mem._llm.stream is False def test_memory_record_defaults() -> None: diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py index f676ae0b8..5d5c35002 100644 --- a/lib/devtools/src/crewai_devtools/__init__.py +++ b/lib/devtools/src/crewai_devtools/__init__.py @@ -1,3 +1,3 @@ """CrewAI development tools.""" -__version__ = "1.14.8a" +__version__ = "1.14.8a1" diff --git a/pyproject.toml b/pyproject.toml index 018988af4..d36586f4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -171,6 +171,8 @@ info = "Commits must follow Conventional Commits 1.0.0." [tool.uv] exclude-newer = "3 days" +# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff. +exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" } # composio-core pins rich<14 but textual requires rich>=14. # onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10. @@ -180,7 +182,8 @@ exclude-newer = "3 days" # langchain-text-splitters <1.1.2 has GHSA-fv5p-p927-qmxr (SSRF bypass in split_text_from_url). # transformers 4.57.6 has CVE-2026-1839; force 5.4+ (docling 2.84 allows huggingface-hub>=1). # cryptography 46.0.6 has CVE-2026-39892; force 46.0.7+. -# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p; force 6.10.2+. +# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p. +# pypdf <6.13.3 has GHSA-jm82-fx9c-mx94; force 6.13.3+. # uv <0.11.15 has GHSA-4gg8-gxpx-9rph (and earlier GHSA-pjjw-68hj-v9mw); force 0.11.15+. # python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers). # gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath). @@ -205,7 +208,7 @@ override-dependencies = [ "urllib3>=2.7.0", "transformers>=5.4.0; python_version >= '3.10'", "cryptography>=46.0.7", - "pypdf>=6.10.2,<7", + "pypdf>=6.13.3,<7", "uv>=0.11.15,<1", "python-multipart>=0.0.27,<1", "gitpython>=3.1.50,<4", diff --git a/uv.lock b/uv.lock index 1ebe9c0b5..d2a5c37fc 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,9 @@ resolution-markers = [ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" +[options.exclude-newer-package] +pypdf = "2026-06-18T00:00:00Z" + [manifest] members = [ "crewai", @@ -40,7 +43,7 @@ overrides = [ { name = "pillow", specifier = ">=12.1.1" }, { name = "pip", specifier = ">=26.1.2" }, { name = "pydantic-settings", specifier = ">=2.14.0" }, - { name = "pypdf", specifier = ">=6.10.2,<7" }, + { name = "pypdf", specifier = ">=6.13.3,<7" }, { name = "python-multipart", specifier = ">=0.0.27,<1" }, { name = "rich", specifier = ">=13.7.1" }, { name = "starlette", specifier = ">=1.3.1" }, @@ -1584,7 +1587,7 @@ requires-dist = [ { name = "aiofiles", specifier = "~=24.1.0" }, { name = "av", specifier = "~=13.0.0" }, { name = "pillow", specifier = "~=12.1.1" }, - { name = "pypdf", specifier = "~=6.10.0" }, + { name = "pypdf", specifier = "~=6.13.3" }, { name = "python-magic", specifier = ">=0.4.27" }, { name = "tinytag", specifier = "~=2.2.1" }, ] @@ -7188,14 +7191,14 @@ wheels = [ [[package]] name = "pypdf" -version = "6.13.1" +version = "6.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/d9/9d12fa0d9660d03320725ff686c961b645a4218940a82296e1272d9e1ff0/pypdf-6.13.1.tar.gz", hash = "sha256:4841d8a4c1589e5833915dc0c7ddfacff80a2e0bcbeb5d1e681fecaa1674b03a", size = 6477811, upload-time = "2026-06-08T11:01:49.344Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/18/9947cc201af9ccf76720fd3347bf4f70eb882ce3fcf4cb05f7443e4cf871/pypdf-6.13.3.tar.gz", hash = "sha256:f3cb822769725f1bac658c406cfc9460399043f3750c2d3e4650e0a85eacabd7", size = 6484063, upload-time = "2026-06-17T15:22:00.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/dd/8f03e0a5788a5d1feb4550617c3e6db5e9099eaee248a3e482ddaeacbbb0/pypdf-6.13.1-py3-none-any.whl", hash = "sha256:e555e4ce3f561ef069307622f1374136ba964ca6ca24f24158701decaf83ed9b", size = 346259, upload-time = "2026-06-08T11:01:47.741Z" }, + { url = "https://files.pythonhosted.org/packages/94/56/2967e621598987905fb8cdfadd8f8de6b5c68c9351f0523c4df8409f28f1/pypdf-6.13.3-py3-none-any.whl", hash = "sha256:c6e3f86afb625791510b02ad5480e94b63970bb957df75d44657c282ecc52224", size = 347288, upload-time = "2026-06-17T15:21:59.512Z" }, ] [[package]]