From d59b96720fe90118f661f472dc2a27064451e474 Mon Sep 17 00:00:00 2001 From: wzj1228516103 <154067345+wzj1228516103@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:08:34 +0800 Subject: [PATCH] fix(project): avoid retaining crew instances in memoization cache --- lib/crewai/src/crewai/project/utils.py | 40 +++++++++++++++++++++++--- lib/crewai/tests/test_project.py | 26 +++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/crewai/src/crewai/project/utils.py b/lib/crewai/src/crewai/project/utils.py index b46a4dc442..e9166a0724 100644 --- a/lib/crewai/src/crewai/project/utils.py +++ b/lib/crewai/src/crewai/project/utils.py @@ -3,7 +3,9 @@ from collections.abc import Callable, Coroutine from functools import wraps import inspect +import threading from typing import Any, ParamSpec, TypeVar, cast +from weakref import WeakKeyDictionary from pydantic import BaseModel @@ -13,6 +15,30 @@ P = ParamSpec("P") R = TypeVar("R") cache = CacheHandler() +_instance_caches: WeakKeyDictionary[Any, CacheHandler] = WeakKeyDictionary() +_instance_caches_lock = threading.Lock() + + +def _get_cache(args: tuple[Any, ...]) -> CacheHandler: + """Return an instance-scoped cache when memoizing a bound method. + + Instance caches are held by weak keys so cached Agent/Task/Crew results do + not keep discarded CrewBase instances alive. Functions without an instance + argument continue using the process-wide cache. + """ + if not args: + return cache + + instance = args[0] + if not hasattr(instance, "__dict__"): + return cache + + try: + with _instance_caches_lock: + return _instance_caches.setdefault(instance, CacheHandler()) + except TypeError: + # Some callable objects are unhashable or cannot be weakly referenced. + return cache def _make_hashable(arg: Any) -> Any: @@ -63,12 +89,15 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: ) cache_key = str((hashable_args, hashable_kwargs)) - cached_result: R | None = cache.read(tool=meth.__name__, input=cache_key) + instance_cache = _get_cache(tuple(args)) + cached_result: R | None = instance_cache.read( + tool=meth.__name__, input=cache_key + ) if cached_result is not None: return cached_result result = meth(*args, **kwargs) - cache.add(tool=meth.__name__, input=cache_key, output=result) + instance_cache.add(tool=meth.__name__, input=cache_key, output=result) return result return cast(Callable[P, R], wrapper) @@ -87,12 +116,15 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: ) cache_key = str((hashable_args, hashable_kwargs)) - cached_result: R | None = cache.read(tool=meth.__name__, input=cache_key) + instance_cache = _get_cache(tuple(args)) + cached_result: R | None = instance_cache.read( + tool=meth.__name__, input=cache_key + ) if cached_result is not None: return cached_result result = await meth(*args, **kwargs) - cache.add(tool=meth.__name__, input=cache_key, output=result) + instance_cache.add(tool=meth.__name__, input=cache_key, output=result) return result return wrapper diff --git a/lib/crewai/tests/test_project.py b/lib/crewai/tests/test_project.py index 1cfe8e2227..367ef87462 100644 --- a/lib/crewai/tests/test_project.py +++ b/lib/crewai/tests/test_project.py @@ -1,3 +1,4 @@ +import gc from typing import Any, ClassVar, cast from unittest.mock import Mock, create_autospec, patch @@ -16,6 +17,7 @@ llm, task, ) +from crewai.project import utils as project_utils from crewai.task import Task from crewai.tools import tool @@ -137,6 +139,30 @@ def test_crew_memoization(): ) +def test_instance_memoization_cache_does_not_retain_discarded_crews(): + """Memoized CrewBase results should be released with their owner instance.""" + project_utils._instance_caches.clear() + + class CrewFactory: + @agent + def simple_agent(self): + return Agent( + role="Simple Agent", goal="Simple Goal", backstory="Simple Backstory" + ) + + factories = [CrewFactory() for _ in range(10)] + results = [factory.simple_agent() for factory in factories] + + assert len(project_utils._instance_caches) == len(factories) + assert len({id(result) for result in results}) == len(factories) + + del results + del factories + gc.collect() + + assert len(project_utils._instance_caches) == 0 + + def test_task_name(): simple_task = SimpleCrew().simple_task() assert simple_task.name == "simple_task", (