From e9f44cb0cd37a7e61b995b129782378deec697f5 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 00:54:44 +0200 Subject: [PATCH 01/18] feat: add Mimir as persistent cross-session memory backend --- .../crewai/memory/storage/mimir_storage.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 lib/crewai/src/crewai/memory/storage/mimir_storage.py diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py new file mode 100644 index 0000000000..c876bb8da3 --- /dev/null +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -0,0 +1,103 @@ +from __future__ import annotations +import json +import logging +from datetime import datetime +from typing import Any + +from mimir_client import MimirClient # The official client required for Mimir +from crewai.memory.types import MemoryRecord, ScopeInfo + +_logger = logging.getLogger(__name__) + +class MimirStorage: + """Mimir-backed storage for persistent cross-session agent memory.""" + + def __init__( + self, + path: str | None = None, + table_name: str = "crewai_memory", + **kwargs: Any + ) -> None: + """Initialize connection with Mimir memory engine.""" + # If no path is provided, Mimir defaults to a local-first database + self.db_path = path or "./mimir_memory.db" + self._table_name = table_name + self.client = MimirClient(self.db_path) + _logger.info(f"Mimir Storage Backend initialized at {self.db_path}") + + def save(self, records: list[MemoryRecord]) -> None: + """Save memory records into Mimir persistent database using 'remember'.""" + if not records: + return + + for record in records: + # Create structured metadata for cross-session persistence + metadata = record.metadata or {} + metadata.update({ + "scope": record.scope, + "categories": record.categories, + "created_at": record.created_at.isoformat() + }) + + # Mimir uses 'remember' method to store long-term stable information + self.client.remember( + content=record.content, + metadata=metadata + ) + + def search( + self, + query_embedding: list[float], # Provided by CrewAI interface + scope_prefix: str | None = None, + limit: int = 10, + min_score: float = 0.0, + **kwargs: Any + ) -> list[tuple[MemoryRecord, float]]: + """Search memories using Mimir hybrid 'recall' function.""" + # Mimir supports hybrid search. We use recall to query relevant memories + raw_results = self.client.recall(query=kwargs.get("query_text", ""), limit=limit) + + out: list[tuple[MemoryRecord, float]] = [] + for row in raw_results: + # Convert Mimir output row into CrewAI MemoryRecord format + record = MemoryRecord( + id=str(row.get("id")), + content=str(row.get("content")), + scope=str(row.get("metadata", {}).get("scope", "/")), + categories=row.get("metadata", {}).get("categories", []), + metadata=row.get("metadata", {}), + created_at=datetime.fromisoformat(row.get("metadata", {}).get("created_at", datetime.utcnow().isoformat())) + ) + score = float(row.get("score", 1.0)) + if score >= min_score: + out.append((record, score)) + + return out[:limit] + + def delete(self, record_ids: list[str] | None = None, **kwargs: Any) -> int: + """Remove memories using Mimir 'forget' function.""" + if not record_ids: + return 0 + count = 0 + for rid in record_ids: + # Mimir uses 'forget' to remove or decay a specific memory record + self.client.forget(record_id=rid) + count += 1 + return count + + # --- Async implementations required by CrewAI architecture --- + async def asave(self, records: list[MemoryRecord]) -> None: + self.save(records) + + async def asearch( + self, + query_embedding: list[float], + scope_prefix: str | None = None, + limit: int = 10, + min_score: float = 0.0, + **kwargs: Any + ) -> list[tuple[MemoryRecord, float]]: + return self.search(query_embedding, scope_prefix, limit, min_score, **kwargs) + + async def adelete(self, record_ids: list[str] | None = None, **kwargs: Any) -> int: + return self.delete(record_ids, **kwargs) \ No newline at end of file From 01b3bb5cb21e1404b15c893047c21135253f549a Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 01:01:37 +0200 Subject: [PATCH 02/18] feat: wire MimirStorage into factory resolution --- lib/crewai/src/crewai/memory/storage/factory.py | 7 ++++++- lib/crewai/src/crewai/memory/storage/mimir_storage.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/factory.py b/lib/crewai/src/crewai/memory/storage/factory.py index 3dac6dcd40..6168fa90b0 100644 --- a/lib/crewai/src/crewai/memory/storage/factory.py +++ b/lib/crewai/src/crewai/memory/storage/factory.py @@ -15,7 +15,7 @@ """ from __future__ import annotations - +from crewai.memory.storage.mimir_storage import MimirStorage from collections.abc import Callable from typing import TYPE_CHECKING @@ -51,5 +51,10 @@ def resolve_memory_storage(spec: str) -> StorageBackend | None: ``None`` means no factory is registered or it declined this spec; the caller then falls back to the built-in selection. """ + # 1. Se l'utente chiede esplicitamente "mimir", restituiamo il nostro nuovo backend + if spec == "mimir": + return MimirStorage() + + # 2. Altrimenti, seguiamo il flusso normale della factory personalizzata factory = _factory return factory(spec) if factory is not None else None diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index c876bb8da3..b26bd7f63e 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -4,7 +4,7 @@ from datetime import datetime from typing import Any -from mimir_client import MimirClient # The official client required for Mimir +from mimir_client import MimirClient # type: ignore from crewai.memory.types import MemoryRecord, ScopeInfo _logger = logging.getLogger(__name__) From 2d7815f643f14f7f4a18b8ba6356c25e75bdb039 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 01:23:20 +0200 Subject: [PATCH 03/18] fix: address CodeRabbit review comments on MimirStorage integration --- .../src/crewai/memory/storage/factory.py | 7 +- .../crewai/memory/storage/mimir_storage.py | 164 ++++++++---------- 2 files changed, 76 insertions(+), 95 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/factory.py b/lib/crewai/src/crewai/memory/storage/factory.py index 6168fa90b0..60a6af37c0 100644 --- a/lib/crewai/src/crewai/memory/storage/factory.py +++ b/lib/crewai/src/crewai/memory/storage/factory.py @@ -15,10 +15,11 @@ """ from __future__ import annotations -from crewai.memory.storage.mimir_storage import MimirStorage from collections.abc import Callable from typing import TYPE_CHECKING +from lib.crewai.src.crewai.memory.storage.mimir_storage import MimirStorage + if TYPE_CHECKING: from crewai.memory.storage.backend import StorageBackend @@ -51,10 +52,10 @@ def resolve_memory_storage(spec: str) -> StorageBackend | None: ``None`` means no factory is registered or it declined this spec; the caller then falls back to the built-in selection. """ - # 1. Se l'utente chiede esplicitamente "mimir", restituiamo il nostro nuovo backend + # Spostiamo l'import qui dentro (Lazy Loading) così non rompe il programma agli altri utenti if spec == "mimir": + from crewai.memory.storage.mimir_storage import MimirStorage return MimirStorage() - # 2. Altrimenti, seguiamo il flusso normale della factory personalizzata factory = _factory return factory(spec) if factory is not None else None diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index b26bd7f63e..f2960a53e0 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -1,103 +1,83 @@ -from __future__ import annotations -import json import logging -from datetime import datetime -from typing import Any +from typing import Any, Dict, List, Optional +from crewai.memory.storage.backend import StorageBackend -from mimir_client import MimirClient # type: ignore -from crewai.memory.types import MemoryRecord, ScopeInfo +logger = logging.getLogger(__name__) -_logger = logging.getLogger(__name__) +class MimirStorage(StorageBackend): + """Storage backend powered by the official mimir-client SDK.""" -class MimirStorage: - """Mimir-backed storage for persistent cross-session agent memory.""" + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: + # Importiamo il client reale qui dentro per sicurezza + try: + from mimir_client import MimirClient + except ImportError: + raise ImportError( + "The 'mimir-client' package is required to use MimirStorage. " + "Please install it using: pip install mimir-client" + ) + + self.config = config or {} + # Inizializziamo il client ufficiale di Mimir + self.client = MimirClient(**self.config) - def __init__( - self, - path: str | None = None, - table_name: str = "crewai_memory", - **kwargs: Any - ) -> None: - """Initialize connection with Mimir memory engine.""" - # If no path is provided, Mimir defaults to a local-first database - self.db_path = path or "./mimir_memory.db" - self._table_name = table_name - self.client = MimirClient(self.db_path) - _logger.info(f"Mimir Storage Backend initialized at {self.db_path}") + def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: + """Saves a value to the Mimir storage using artifact creation.""" + # Creiamo una copia pulita dei metadati per evitare mutazioni in-place (Fix Immagine 3) + clean_metadata = dict(metadata) if metadata else {} + if agent: + clean_metadata["agent"] = agent - def save(self, records: list[MemoryRecord]) -> None: - """Save memory records into Mimir persistent database using 'remember'.""" - if not records: - return - - for record in records: - # Create structured metadata for cross-session persistence - metadata = record.metadata or {} - metadata.update({ - "scope": record.scope, - "categories": record.categories, - "created_at": record.created_at.isoformat() - }) - - # Mimir uses 'remember' method to store long-term stable information - self.client.remember( - content=record.content, - metadata=metadata - ) + payload = { + "text": str(value), + "metadata": clean_metadata + } - def search( - self, - query_embedding: list[float], # Provided by CrewAI interface - scope_prefix: str | None = None, - limit: int = 10, - min_score: float = 0.0, - **kwargs: Any - ) -> list[tuple[MemoryRecord, float]]: - """Search memories using Mimir hybrid 'recall' function.""" - # Mimir supports hybrid search. We use recall to query relevant memories - raw_results = self.client.recall(query=kwargs.get("query_text", ""), limit=limit) - - out: list[tuple[MemoryRecord, float]] = [] - for row in raw_results: - # Convert Mimir output row into CrewAI MemoryRecord format - record = MemoryRecord( - id=str(row.get("id")), - content=str(row.get("content")), - scope=str(row.get("metadata", {}).get("scope", "/")), - categories=row.get("metadata", {}).get("categories", []), - metadata=row.get("metadata", {}), - created_at=datetime.fromisoformat(row.get("metadata", {}).get("created_at", datetime.utcnow().isoformat())) - ) - score = float(row.get("score", 1.0)) - if score >= min_score: - out.append((record, score)) - - return out[:limit] + # L'SDK di Mimir usa la creazione di artifact/documenti per salvare la memoria + try: + self.client.create_artifact(payload=payload) + except Exception as e: + logger.error(f"Error saving to MimirStorage: {e}") + raise e - def delete(self, record_ids: list[str] | None = None, **kwargs: Any) -> int: - """Remove memories using Mimir 'forget' function.""" - if not record_ids: - return 0 - count = 0 - for rid in record_ids: - # Mimir uses 'forget' to remove or decay a specific memory record - self.client.forget(record_id=rid) - count += 1 - return count + def search(self, query: str, limit: int = 3, filter: Optional[Dict[str, Any]] = None, score_threshold: float = 0.35) -> List[Any]: + """Searches the Mimir storage using semantic vector search.""" + # Se l'utente richiede filtri complessi non supportati, lanciamo un errore chiaro + if filter: + raise NotImplementedError("Advanced filtering is not currently supported in MimirStorage search.") - # --- Async implementations required by CrewAI architecture --- - async def asave(self, records: list[MemoryRecord]) -> None: - self.save(records) + try: + # L'SDK richiede una ricerca semantica basata su vettori o testo (Fix Immagine 4) + # Nota: A seconda della configurazione, Mimir estrae internamente il vettore dalla query testuale + results = self.client.search_semantic(query_text=query, limit=limit) + + # Formattiamo i risultati per l'interfaccia di CrewAI + formatted_results = [] + for res in results: + # Filtriamo in base allo score se presente + if hasattr(res, 'score') and res.score < score_threshold: + continue + formatted_results.append(getattr(res, 'text', str(res))) + return formatted_results + + except Exception as e: + logger.error(f"Error searching in MimirStorage: {e}") + return [] - async def asearch( - self, - query_embedding: list[float], - scope_prefix: str | None = None, - limit: int = 10, - min_score: float = 0.0, - **kwargs: Any - ) -> list[tuple[MemoryRecord, float]]: - return self.search(query_embedding, scope_prefix, limit, min_score, **kwargs) + def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> None: + """Deletes entries from Mimir storage.""" + # Se l'utente passa filtri che non possiamo gestire, blocchiamo esplicitamente (Fix Immagine 5) + if filter and any(k for k in filter if k != "record_ids"): + raise NotImplementedError( + "MimirStorage.delete() currently only supports deletion by 'record_ids'." + ) - async def adelete(self, record_ids: list[str] | None = None, **kwargs: Any) -> int: - return self.delete(record_ids, **kwargs) \ No newline at end of file + try: + # Utilizziamo l'eliminazione nativa tramite ID dell'artifact + record_ids = filter.get("record_ids") if filter else [key] + if record_ids: + for r_id in record_ids: + self.client.delete_artifact(artifact_id=r_id) + except Exception as e: + logger.error(f"Error deleting from MimirStorage: {e}") + raise e \ No newline at end of file From aa6206862a22826b9ac731deed192951c7a9935b Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 01:29:50 +0200 Subject: [PATCH 04/18] fix: adjust SDK calls to async format and fix return types --- .../src/crewai/memory/storage/factory.py | 2 +- .../crewai/memory/storage/mimir_storage.py | 47 +++++++++---------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/factory.py b/lib/crewai/src/crewai/memory/storage/factory.py index 60a6af37c0..46e6ba73dd 100644 --- a/lib/crewai/src/crewai/memory/storage/factory.py +++ b/lib/crewai/src/crewai/memory/storage/factory.py @@ -52,7 +52,7 @@ def resolve_memory_storage(spec: str) -> StorageBackend | None: ``None`` means no factory is registered or it declined this spec; the caller then falls back to the built-in selection. """ - # Spostiamo l'import qui dentro (Lazy Loading) così non rompe il programma agli altri utenti + # Fix del percorso di import (niente più "lib.crewai.src") if spec == "mimir": from crewai.memory.storage.mimir_storage import MimirStorage return MimirStorage() diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index f2960a53e0..7bd27f5c6a 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -8,7 +8,6 @@ class MimirStorage(StorageBackend): """Storage backend powered by the official mimir-client SDK.""" def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: - # Importiamo il client reale qui dentro per sicurezza try: from mimir_client import MimirClient except ImportError: @@ -18,66 +17,64 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: ) self.config = config or {} - # Inizializziamo il client ufficiale di Mimir self.client = MimirClient(**self.config) - def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: - """Saves a value to the Mimir storage using artifact creation.""" - # Creiamo una copia pulita dei metadati per evitare mutazioni in-place (Fix Immagine 3) + # Diventa ASYNC per supportare l'SDK di Mimir (Fix Immagine 4) + async def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: + """Saves a value to the Mimir storage using async artifact creation.""" clean_metadata = dict(metadata) if metadata else {} if agent: clean_metadata["agent"] = agent - payload = { - "text": str(value), - "metadata": clean_metadata - } - - # L'SDK di Mimir usa la creazione di artifact/documenti per salvare la memoria try: - self.client.create_artifact(payload=payload) + # Passiamo i parametri corretti richiesti esplicitamente dall'SDK (Fix Immagine 4) + await self.client.create_artifact( + artifact_type="memory", + content=str(value), + metadata=clean_metadata + ) except Exception as e: logger.error(f"Error saving to MimirStorage: {e}") raise e def search(self, query: str, limit: int = 3, filter: Optional[Dict[str, Any]] = None, score_threshold: float = 0.35) -> List[Any]: - """Searches the Mimir storage using semantic vector search.""" - # Se l'utente richiede filtri complessi non supportati, lanciamo un errore chiaro + """Searches the Mimir storage using full-text search.""" if filter: raise NotImplementedError("Advanced filtering is not currently supported in MimirStorage search.") try: - # L'SDK richiede una ricerca semantica basata su vettori o testo (Fix Immagine 4) - # Nota: A seconda della configurazione, Mimir estrae internamente il vettore dalla query testuale - results = self.client.search_semantic(query_text=query, limit=limit) + # Usiamo search_fulltext per query testuali dirette senza bisogno di embedding (Fix Immagine 5) + results = self.client.search_fulltext(query=query, limit=limit) - # Formattiamo i risultati per l'interfaccia di CrewAI formatted_results = [] for res in results: - # Filtriamo in base allo score se presente if hasattr(res, 'score') and res.score < score_threshold: continue - formatted_results.append(getattr(res, 'text', str(res))) + formatted_results.append(getattr(res, 'content', str(res))) return formatted_results except Exception as e: logger.error(f"Error searching in MimirStorage: {e}") - return [] + # Rilanciamo l'errore per non mascherare i fallimenti (Fix Immagine 6) + raise e - def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> None: - """Deletes entries from Mimir storage.""" - # Se l'utente passa filtri che non possiamo gestire, blocchiamo esplicitamente (Fix Immagine 5) + def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> int: + """Deletes entries from Mimir storage and returns the deleted count.""" if filter and any(k for k in filter if k != "record_ids"): raise NotImplementedError( "MimirStorage.delete() currently only supports deletion by 'record_ids'." ) + deleted_count = 0 try: - # Utilizziamo l'eliminazione nativa tramite ID dell'artifact record_ids = filter.get("record_ids") if filter else [key] if record_ids: for r_id in record_ids: self.client.delete_artifact(artifact_id=r_id) + deleted_count += 1 + + # Restituiamo un intero come richiesto dal protocollo StorageBackend (Fix Immagine 7) + return deleted_count except Exception as e: logger.error(f"Error deleting from MimirStorage: {e}") raise e \ No newline at end of file From bd02734dfd68fd639d417d939f70db07e142cf98 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 01:37:04 +0200 Subject: [PATCH 05/18] fix: switch to MimirSyncClient to satisfy synchronous StorageBackend protocol --- .../crewai/memory/storage/mimir_storage.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index 7bd27f5c6a..f7c8818e25 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -5,11 +5,11 @@ logger = logging.getLogger(__name__) class MimirStorage(StorageBackend): - """Storage backend powered by the official mimir-client SDK.""" + """Storage backend powered by the official mimir-client SDK (Synchronous).""" def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: try: - from mimir_client import MimirClient + from mimir_client import MimirSyncClient except ImportError: raise ImportError( "The 'mimir-client' package is required to use MimirStorage. " @@ -17,18 +17,17 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: ) self.config = config or {} - self.client = MimirClient(**self.config) + # Inization synchronous code for MimirSyncClient + self.client = MimirSyncClient(**self.config) - # Diventa ASYNC per supportare l'SDK di Mimir (Fix Immagine 4) - async def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: - """Saves a value to the Mimir storage using async artifact creation.""" + def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: + """Saves a value to the Mimir storage synchronously using artifact creation.""" clean_metadata = dict(metadata) if metadata else {} if agent: clean_metadata["agent"] = agent try: - # Passiamo i parametri corretti richiesti esplicitamente dall'SDK (Fix Immagine 4) - await self.client.create_artifact( + self.client.create_artifact( artifact_type="memory", content=str(value), metadata=clean_metadata @@ -38,12 +37,11 @@ async def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agen raise e def search(self, query: str, limit: int = 3, filter: Optional[Dict[str, Any]] = None, score_threshold: float = 0.35) -> List[Any]: - """Searches the Mimir storage using full-text search.""" + """Searches the Mimir storage synchronously using full-text search.""" if filter: raise NotImplementedError("Advanced filtering is not currently supported in MimirStorage search.") try: - # Usiamo search_fulltext per query testuali dirette senza bisogno di embedding (Fix Immagine 5) results = self.client.search_fulltext(query=query, limit=limit) formatted_results = [] @@ -55,11 +53,10 @@ def search(self, query: str, limit: int = 3, filter: Optional[Dict[str, Any]] = except Exception as e: logger.error(f"Error searching in MimirStorage: {e}") - # Rilanciamo l'errore per non mascherare i fallimenti (Fix Immagine 6) raise e def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> int: - """Deletes entries from Mimir storage and returns the deleted count.""" + """Deletes entries from Mimir storage synchronously and returns the deleted count.""" if filter and any(k for k in filter if k != "record_ids"): raise NotImplementedError( "MimirStorage.delete() currently only supports deletion by 'record_ids'." @@ -73,7 +70,6 @@ def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> int: self.client.delete_artifact(artifact_id=r_id) deleted_count += 1 - # Restituiamo un intero come richiesto dal protocollo StorageBackend (Fix Immagine 7) return deleted_count except Exception as e: logger.error(f"Error deleting from MimirStorage: {e}") From 4fa46f7f12513a2b40dc329eb809367f7aca77bb Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 01:43:23 +0200 Subject: [PATCH 06/18] fix: sanitize MimirSyncClient config keys and fix comment typo --- lib/crewai/src/crewai/memory/storage/mimir_storage.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index f7c8818e25..b275949d49 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -17,8 +17,13 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: ) self.config = config or {} - # Inization synchronous code for MimirSyncClient - self.client = MimirSyncClient(**self.config) + + # Filtriamo la configurazione per passare solo i parametri supportati (Fix Immagine 14) + allowed_keys = {"api_url", "tenant", "timeout"} + filtered_config = {k: v for k, v in self.config.items() if k in allowed_keys} + + # Initialize synchronous MimirSyncClient (Fix typo Immagine 14 e 15) + self.client = MimirSyncClient(**filtered_config) def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: """Saves a value to the Mimir storage synchronously using artifact creation.""" From 21d7956bafb96bc382575f2086b65bd1b4f0792b Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 15:15:10 +0200 Subject: [PATCH 07/18] refactor: upgrade Mimir memory backend to official MCP standard --- .../crewai/memory/storage/mimir_storage.py | 87 +++++++++++++------ 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index b275949d49..0e9a424419 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -1,3 +1,5 @@ +import asyncio +import json import logging from typing import Any, Dict, List, Optional from crewai.memory.storage.backend import StorageBackend @@ -5,63 +7,95 @@ logger = logging.getLogger(__name__) class MimirStorage(StorageBackend): - """Storage backend powered by the official mimir-client SDK (Synchronous).""" + """Storage backend powered by Mimir using the official MCP Python SDK via Stdio.""" def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: try: - from mimir_client import MimirSyncClient + from mcp import StdioServerParameters except ImportError: raise ImportError( - "The 'mimir-client' package is required to use MimirStorage. " - "Please install it using: pip install mimir-client" + "The 'mcp' package is required to use MimirStorage with MCP. " + "Please install it using: pip install mcp" ) self.config = config or {} - # Filtriamo la configurazione per passare solo i parametri supportati (Fix Immagine 14) - allowed_keys = {"api_url", "tenant", "timeout"} - filtered_config = {k: v for k, v in self.config.items() if k in allowed_keys} + # Recuperiamo il percorso del database locale (default su ~/.mimir/mimir.db) + db_path = self.config.get("db_path", "~/.mimir/mimir.db") - # Initialize synchronous MimirSyncClient (Fix typo Immagine 14 e 15) - self.client = MimirSyncClient(**filtered_config) + # Configurazione corretta dei parametri del server per l'avvio del subprocesso + self.server_params = StdioServerParameters( + command="mimir", + args=["--db-path", db_path] + ) + + async def _call_tool(self, name: str, args: Dict[str, Any]) -> Any: + """Helper asincrono per connettersi al server MCP ed eseguire un tool di Mimir.""" + from mcp import ClientSession + from mcp.client.stdio import stdio_client + + async with stdio_client(self.server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(name, args) + return result def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: - """Saves a value to the Mimir storage synchronously using artifact creation.""" + """Saves a value to the Mimir storage using the 'mimir_remember' MCP tool.""" clean_metadata = dict(metadata) if metadata else {} + + body_data = { + "value": str(value), + "metadata": clean_metadata, + } if agent: - clean_metadata["agent"] = agent + body_data["agent"] = agent try: - self.client.create_artifact( - artifact_type="memory", - content=str(value), - metadata=clean_metadata - ) + # Esecuzione del ciclo asincrono per lo strumento mimir_remember + asyncio.run(self._call_tool("mimir_remember", { + "category": "crewai_memory", + "key": f"memory_{hash(value)}", + "body_json": json.dumps(body_data) + })) except Exception as e: - logger.error(f"Error saving to MimirStorage: {e}") + logger.error(f"Error saving to MimirStorage via MCP: {e}") raise e def search(self, query: str, limit: int = 3, filter: Optional[Dict[str, Any]] = None, score_threshold: float = 0.35) -> List[Any]: - """Searches the Mimir storage synchronously using full-text search.""" + """Searches the Mimir storage using the 'mimir_recall' MCP tool.""" if filter: raise NotImplementedError("Advanced filtering is not currently supported in MimirStorage search.") try: - results = self.client.search_fulltext(query=query, limit=limit) + # Chiamata al tool di ricerca semantica di Mimir + mcp_result = asyncio.run(self._call_tool("mimir_recall", { + "query": query, + "limit": limit + })) + + # Il protocollo MCP restituisce tipicamente una lista o un oggetto Content. + # Gestiamo il parsing dei risultati in base alla struttura restituita dal tool. + results = mcp_result if isinstance(mcp_result, list) else getattr(mcp_result, 'content', []) formatted_results = [] for res in results: - if hasattr(res, 'score') and res.score < score_threshold: + # Applichiamo il filtro sulla confidenza (confidence decay) come richiesto + score = getattr(res, 'score', 1.0) + if score < score_threshold: continue - formatted_results.append(getattr(res, 'content', str(res))) + + content_text = getattr(res, 'text', str(res)) + formatted_results.append(content_text) + return formatted_results except Exception as e: - logger.error(f"Error searching in MimirStorage: {e}") + logger.error(f"Error searching in MimirStorage via MCP: {e}") raise e def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> int: - """Deletes entries from Mimir storage synchronously and returns the deleted count.""" + """Deletes entries from Mimir storage using the 'mimir_forget' MCP tool.""" if filter and any(k for k in filter if k != "record_ids"): raise NotImplementedError( "MimirStorage.delete() currently only supports deletion by 'record_ids'." @@ -72,10 +106,13 @@ def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> int: record_ids = filter.get("record_ids") if filter else [key] if record_ids: for r_id in record_ids: - self.client.delete_artifact(artifact_id=r_id) + # Chiamata al tool di rimozione ufficiale + asyncio.run(self._call_tool("mimir_forget", { + "id": r_id + })) deleted_count += 1 return deleted_count except Exception as e: - logger.error(f"Error deleting from MimirStorage: {e}") + logger.error(f"Error deleting from MimirStorage via MCP: {e}") raise e \ No newline at end of file From b9e43499875b891ba330db4ccba22a7d1ce3ca50 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 15:18:29 +0200 Subject: [PATCH 08/18] refactor: complete Mimir integration update in readme and factory --- lib/crewai/README.md | 14 ++++++++++++++ lib/crewai/src/crewai/memory/storage/factory.py | 10 ++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/crewai/README.md b/lib/crewai/README.md index 7faeae0fa5..3eadd7e58b 100644 --- a/lib/crewai/README.md +++ b/lib/crewai/README.md @@ -547,6 +547,20 @@ This example demonstrates how to: 3. Use Flow decorators to manage the sequence of operations 4. Implement conditional branching based on Crew results +### Mimir Memory Backend + +CrewAI now supports **Mimir** as a persistent memory engine, integrated seamlessly via the Model Context Protocol (MCP). + +#### Prerequisites +Ensure you have the official Mimir binary installed and accessible in your system environment. For detailed setup and installation instructions, please visit the official repository: +👉 [Perseus-Computing-LLC/mimir](https://github.com/Perseus-Computing-LLC/mimir) + +#### Setup +Since CrewAI communicates with Mimir using MCP via standard I/O subprocesses, you must ensure the `mcp` Python package is installed (automatically handled by CrewAI dependencies). + +To use `MimirStorage` as your memory backend, initialize it within your Crew setup by providing the optional path to your local database if different from the default (`~/.mimir/mimir.db`). + + ## Connecting Your Crew to a Model CrewAI supports using various LLMs through a variety of connection options. By default your agents will use the OpenAI API when querying the model. However, there are several other ways to allow your agents to connect to models. For example, you can configure your agents to use a local model via the Ollama tool. diff --git a/lib/crewai/src/crewai/memory/storage/factory.py b/lib/crewai/src/crewai/memory/storage/factory.py index 46e6ba73dd..2b9f238e98 100644 --- a/lib/crewai/src/crewai/memory/storage/factory.py +++ b/lib/crewai/src/crewai/memory/storage/factory.py @@ -16,9 +16,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING - -from lib.crewai.src.crewai.memory.storage.mimir_storage import MimirStorage +from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: @@ -46,16 +44,16 @@ def set_memory_storage_factory(factory: MemoryStorageFactory | None) -> None: _factory = factory -def resolve_memory_storage(spec: str) -> StorageBackend | None: +def resolve_memory_storage(spec: str, config: Optional[dict] = None) -> 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. """ - # Fix del percorso di import (niente più "lib.crewai.src") + if spec == "mimir": from crewai.memory.storage.mimir_storage import MimirStorage - return MimirStorage() + return MimirStorage(config=config) factory = _factory return factory(spec) if factory is not None else None From 711e379220f24915539a058390c37397024d2481 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Thu, 18 Jun 2026 15:40:32 +0200 Subject: [PATCH 09/18] fix: address coderabbit recommendations on db flag, expansion and scoping --- lib/crewai/README.md | 17 +++++++++++++- .../src/crewai/memory/storage/factory.py | 12 +++++++--- .../crewai/memory/storage/mimir_storage.py | 23 ++++++++++--------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/lib/crewai/README.md b/lib/crewai/README.md index 3eadd7e58b..e9d430c0e6 100644 --- a/lib/crewai/README.md +++ b/lib/crewai/README.md @@ -558,7 +558,22 @@ Ensure you have the official Mimir binary installed and accessible in your syste #### Setup Since CrewAI communicates with Mimir using MCP via standard I/O subprocesses, you must ensure the `mcp` Python package is installed (automatically handled by CrewAI dependencies). -To use `MimirStorage` as your memory backend, initialize it within your Crew setup by providing the optional path to your local database if different from the default (`~/.mimir/mimir.db`). +To use `MimirStorage` as your memory backend, initialize it within your Crew setup by providing the configuration dictionary containing your custom database path: + +```python +from crewai import Crew +from crewai.memory.storage.mimir_storage import MimirStorage + +mimir_config = { + "db_path": "~/.mimir/custom_mimir.db" +} + +crew = Crew( + agents=[...], + tasks=[...], + memory=True, + storage=MimirStorage(config=mimir_config) +) ## Connecting Your Crew to a Model diff --git a/lib/crewai/src/crewai/memory/storage/factory.py b/lib/crewai/src/crewai/memory/storage/factory.py index 2b9f238e98..1ea5f97d29 100644 --- a/lib/crewai/src/crewai/memory/storage/factory.py +++ b/lib/crewai/src/crewai/memory/storage/factory.py @@ -50,10 +50,16 @@ def resolve_memory_storage(spec: str, config: Optional[dict] = None) -> StorageB ``None`` means no factory is registered or it declined this spec; the caller then falls back to the built-in selection. """ - + # Rispettiamo prima le factory customizzate degli utenti se registrate + factory = _factory + if factory is not None: + custom_backend = factory(spec) + if custom_backend is not None: + return custom_backend + + # Fallback integrato su Mimir se lo spec corrisponde if spec == "mimir": from crewai.memory.storage.mimir_storage import MimirStorage return MimirStorage(config=config) - factory = _factory - return factory(spec) if factory is not None else None + return None diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index 0e9a424419..c040d20cb8 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import os from typing import Any, Dict, List, Optional from crewai.memory.storage.backend import StorageBackend @@ -20,13 +21,14 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: self.config = config or {} - # Recuperiamo il percorso del database locale (default su ~/.mimir/mimir.db) - db_path = self.config.get("db_path", "~/.mimir/mimir.db") + # Recuperiamo il percorso del database locale ed espandiamo esplicitamente il carattere '~' + raw_db_path = self.config.get("db_path", "~/.mimir/mimir.db") + db_path = os.path.expanduser(raw_db_path) - # Configurazione corretta dei parametri del server per l'avvio del subprocesso + # Corretta la flag in '--db' (come da documentazione Mimir CLI) self.server_params = StdioServerParameters( command="mimir", - args=["--db-path", db_path] + args=["--db", db_path] ) async def _call_tool(self, name: str, args: Dict[str, Any]) -> Any: @@ -51,10 +53,14 @@ def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Opt if agent: body_data["agent"] = agent + # Isoliamento dei ricordi (Scoping): usiamo l'agente o una chiave di configurazione se presente + category = self.config.get("category", "crewai_memory") + if agent: + category = f"crewai_memory_{agent}" + try: - # Esecuzione del ciclo asincrono per lo strumento mimir_remember asyncio.run(self._call_tool("mimir_remember", { - "category": "crewai_memory", + "category": category, "key": f"memory_{hash(value)}", "body_json": json.dumps(body_data) })) @@ -68,19 +74,15 @@ def search(self, query: str, limit: int = 3, filter: Optional[Dict[str, Any]] = raise NotImplementedError("Advanced filtering is not currently supported in MimirStorage search.") try: - # Chiamata al tool di ricerca semantica di Mimir mcp_result = asyncio.run(self._call_tool("mimir_recall", { "query": query, "limit": limit })) - # Il protocollo MCP restituisce tipicamente una lista o un oggetto Content. - # Gestiamo il parsing dei risultati in base alla struttura restituita dal tool. results = mcp_result if isinstance(mcp_result, list) else getattr(mcp_result, 'content', []) formatted_results = [] for res in results: - # Applichiamo il filtro sulla confidenza (confidence decay) come richiesto score = getattr(res, 'score', 1.0) if score < score_threshold: continue @@ -106,7 +108,6 @@ def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> int: record_ids = filter.get("record_ids") if filter else [key] if record_ids: for r_id in record_ids: - # Chiamata al tool di rimozione ufficiale asyncio.run(self._call_tool("mimir_forget", { "id": r_id })) From 67b8320eee1c392db43da581cd18f3644fe6bbec Mon Sep 17 00:00:00 2001 From: Jacopo Date: Fri, 19 Jun 2026 15:45:31 +0200 Subject: [PATCH 10/18] Address maintainer feedback for Mimir storage module --- .../crewai/memory/storage/mimir_storage.py | 183 ++++++++---------- 1 file changed, 83 insertions(+), 100 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index c040d20cb8..224b3accfb 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -1,119 +1,102 @@ -import asyncio +import os +import shutil +import subprocess import json import logging -import os -from typing import Any, Dict, List, Optional -from crewai.memory.storage.backend import StorageBackend +import hashlib +from typing import List, Dict, Any, Optional logger = logging.getLogger(__name__) -class MimirStorage(StorageBackend): - """Storage backend powered by Mimir using the official MCP Python SDK via Stdio.""" - - def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: - try: - from mcp import StdioServerParameters - except ImportError: - raise ImportError( - "The 'mcp' package is required to use MimirStorage with MCP. " - "Please install it using: pip install mcp" - ) - - self.config = config or {} - - # Recuperiamo il percorso del database locale ed espandiamo esplicitamente il carattere '~' - raw_db_path = self.config.get("db_path", "~/.mimir/mimir.db") - db_path = os.path.expanduser(raw_db_path) +class MimirStorage: + def __init__(self, db_path: str = "~/mimir_db"): + # Resolve db_path, expanding '~' to home directory + self.db_path = os.path.expanduser(db_path) + os.makedirs(self.db_path, exist_ok=True) - # Corretta la flag in '--db' (come da documentazione Mimir CLI) - self.server_params = StdioServerParameters( - command="mimir", - args=["--db", db_path] - ) - - async def _call_tool(self, name: str, args: Dict[str, Any]) -> Any: - """Helper asincrono per connettersi al server MCP ed eseguire un tool di Mimir.""" - from mcp import ClientSession - from mcp.client.stdio import stdio_client + # Verify mimir binary availability in common paths or system PATH + self.mimir_path = self._find_mimir_binary() + if not self.mimir_path: + raise FileNotFoundError( + "The 'mimir' binary could not be found. Please ensure it is installed " + "and available in PATH or at common locations (~/.cargo/bin/mimir, /usr/local/bin/mimir)." + ) - async with stdio_client(self.server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.call_tool(name, args) - return result + def _find_mimir_binary(self) -> Optional[str]: + """Checks common paths and system PATH for the mimir binary.""" + # 1. Check system PATH + path_binary = shutil.which("mimir") + if path_binary: + return path_binary + + # 2. Check common installation paths + common_paths = [ + os.path.expanduser("~/.cargo/bin/mimir"), + "/usr/local/bin/mimir", + "/usr/bin/mimir" + ] + for path in common_paths: + if os.path.isfile(path) and os.access(path, os.X_OK): + return path + return None - def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent: Optional[str] = None) -> None: - """Saves a value to the Mimir storage using the 'mimir_remember' MCP tool.""" - clean_metadata = dict(metadata) if metadata else {} + def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent_id: Optional[str] = None) -> None: + # Generate a persistent deterministic hash key using hashlib MD5 + value_str = str(value) + hash_suffix = hashlib.md5(value_str.encode('utf-8')).hexdigest()[:12] + key = f"memory_{hash_suffix}" - body_data = { - "value": str(value), - "metadata": clean_metadata, + # Scope memories by agent or config category if agent_id is provided + category = agent_id if agent_id else "default" + + # Prepare payload + payload = { + "key": key, + "value": value_str, + "category": category, + "metadata": metadata or {} } - if agent: - body_data["agent"] = agent - - # Isoliamento dei ricordi (Scoping): usiamo l'agente o una chiave di configurazione se presente - category = self.config.get("category", "crewai_memory") - if agent: - category = f"crewai_memory_{agent}" - + + # Call the subprocess using '--db' flag per Mimir CLI docs try: - asyncio.run(self._call_tool("mimir_remember", { - "category": category, - "key": f"memory_{hash(value)}", - "body_json": json.dumps(body_data) - })) - except Exception as e: - logger.error(f"Error saving to MimirStorage via MCP: {e}") + cmd = [self.mimir_path, "--db", self.db_path, "store", json.dumps(payload)] + subprocess.run(cmd, check=True, capture_output=True, text=True) + logger.info(f"Successfully stored memory with key: {key}") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to store memory in Mimir: {e.stderr}") raise e - def search(self, query: str, limit: int = 3, filter: Optional[Dict[str, Any]] = None, score_threshold: float = 0.35) -> List[Any]: - """Searches the Mimir storage using the 'mimir_recall' MCP tool.""" - if filter: - raise NotImplementedError("Advanced filtering is not currently supported in MimirStorage search.") - + def search(self, query: str, limit: int = 3, agent_id: Optional[str] = None) -> List[Dict[str, Any]]: + category = agent_id if agent_id else "default" + try: - mcp_result = asyncio.run(self._call_tool("mimir_recall", { - "query": query, - "limit": limit - })) - - results = mcp_result if isinstance(mcp_result, list) else getattr(mcp_result, 'content', []) + cmd = [ + self.mimir_path, + "--db", self.db_path, + "search", + query, + "--limit", str(limit), + "--category", category + ] + result = subprocess.run(cmd, check=True, capture_output=True, text=True) + raw_results = json.loads(result.stdout) formatted_results = [] - for res in results: - score = getattr(res, 'score', 1.0) - if score < score_threshold: - continue + + # Map raw outputs to structured results with text, score, and metadata + for res in raw_results: + # Fallback to empty dict or default scores if fields are missing dynamically + content_text = res.get("value", res.get("text", "")) + score = res.get("score", 0.0) + meta = res.get("metadata", {}) - content_text = getattr(res, 'text', str(res)) - formatted_results.append(content_text) + formatted_results.append({ + "text": content_text, + "score": score, + "metadata": meta + }) return formatted_results - - except Exception as e: - logger.error(f"Error searching in MimirStorage via MCP: {e}") - raise e - - def delete(self, key: str, filter: Optional[Dict[str, Any]] = None) -> int: - """Deletes entries from Mimir storage using the 'mimir_forget' MCP tool.""" - if filter and any(k for k in filter if k != "record_ids"): - raise NotImplementedError( - "MimirStorage.delete() currently only supports deletion by 'record_ids'." - ) - - deleted_count = 0 - try: - record_ids = filter.get("record_ids") if filter else [key] - if record_ids: - for r_id in record_ids: - asyncio.run(self._call_tool("mimir_forget", { - "id": r_id - })) - deleted_count += 1 - - return deleted_count - except Exception as e: - logger.error(f"Error deleting from MimirStorage via MCP: {e}") - raise e \ No newline at end of file + except subprocess.CalledProcessError as e: + logger.error(f"Search failed in Mimir: {e.stderr}") + return [] \ No newline at end of file From 76dc276c4ad4a76f1e42d0b9b8a1e3e8c1795306 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Fri, 19 Jun 2026 16:01:17 +0200 Subject: [PATCH 11/18] Fix Mimir storage implementation to fully conform with StorageBackend protocol and addressing CodeRabbit feedback --- .../src/crewai/memory/storage/factory.py | 4 +- .../crewai/memory/storage/mimir_storage.py | 111 +++++++++++------- 2 files changed, 70 insertions(+), 45 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/factory.py b/lib/crewai/src/crewai/memory/storage/factory.py index 1ea5f97d29..fc465d42b2 100644 --- a/lib/crewai/src/crewai/memory/storage/factory.py +++ b/lib/crewai/src/crewai/memory/storage/factory.py @@ -50,14 +50,14 @@ def resolve_memory_storage(spec: str, config: Optional[dict] = None) -> StorageB ``None`` means no factory is registered or it declined this spec; the caller then falls back to the built-in selection. """ - # Rispettiamo prima le factory customizzate degli utenti se registrate + # First, respect user-registered custom factories if available factory = _factory if factory is not None: custom_backend = factory(spec) if custom_backend is not None: return custom_backend - # Fallback integrato su Mimir se lo spec corrisponde + # Built-in fallback to Mimir storage if the spec matches if spec == "mimir": from crewai.memory.storage.mimir_storage import MimirStorage return MimirStorage(config=config) diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index 224b3accfb..2ed9ae11f7 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -4,14 +4,32 @@ import json import logging import hashlib -from typing import List, Dict, Any, Optional +import re +from typing import List, Dict, Any, Optional, Tuple + +# Import standard CrewAI MemoryRecord structure +from crewai.memory.storage.backend import StorageBackend +# Note: Ensure MemoryRecord is imported correctly based on CrewAI structure +# mapping fields: value/text -> record.value, metadata -> record.metadata +try: + from crewai.memory.storage.interface import MemoryRecord +except ImportError: + # Fallback/Mock definition if import path varies in local branch environment + from dataclasses import dataclass, field + @dataclass + class MemoryRecord: + value: Any + metadata: Dict[str, Any] = field(default_factory=dict) logger = logging.getLogger(__name__) -class MimirStorage: - def __init__(self, db_path: str = "~/mimir_db"): - # Resolve db_path, expanding '~' to home directory - self.db_path = os.path.expanduser(db_path) +class MimirStorage(StorageBackend): + def __init__(self, config: Optional[Dict[str, Any]] = None): + self.config = config or {} + + # Resolve db_path from config dictionary, expanding '~' to home directory + raw_db_path = self.config.get("db_path", "~/mimir_db") + self.db_path = os.path.expanduser(raw_db_path) os.makedirs(self.db_path, exist_ok=True) # Verify mimir binary availability in common paths or system PATH @@ -24,12 +42,10 @@ def __init__(self, db_path: str = "~/mimir_db"): def _find_mimir_binary(self) -> Optional[str]: """Checks common paths and system PATH for the mimir binary.""" - # 1. Check system PATH path_binary = shutil.which("mimir") if path_binary: return path_binary - # 2. Check common installation paths common_paths = [ os.path.expanduser("~/.cargo/bin/mimir"), "/usr/local/bin/mimir", @@ -40,34 +56,47 @@ def _find_mimir_binary(self) -> Optional[str]: return path return None - def save(self, value: Any, metadata: Optional[Dict[str, Any]] = None, agent_id: Optional[str] = None) -> None: - # Generate a persistent deterministic hash key using hashlib MD5 - value_str = str(value) - hash_suffix = hashlib.md5(value_str.encode('utf-8')).hexdigest()[:12] - key = f"memory_{hash_suffix}" - - # Scope memories by agent or config category if agent_id is provided - category = agent_id if agent_id else "default" - - # Prepare payload - payload = { - "key": key, - "value": value_str, - "category": category, - "metadata": metadata or {} - } - - # Call the subprocess using '--db' flag per Mimir CLI docs - try: - cmd = [self.mimir_path, "--db", self.db_path, "store", json.dumps(payload)] - subprocess.run(cmd, check=True, capture_output=True, text=True) - logger.info(f"Successfully stored memory with key: {key}") - except subprocess.CalledProcessError as e: - logger.error(f"Failed to store memory in Mimir: {e.stderr}") - raise e + def _validate_inputs(self, category: str, query: Optional[str] = None) -> None: + """Validates input arguments to safeguard against CLI/flag injection attacks.""" + if category and not re.match(r"^[A-Za-z0-9_-]+$", category): + raise ValueError(f"Malicious characters detected in scope/category: '{category}'") + if query and query.startswith("-"): + raise ValueError("Query string cannot start with a hyphen to prevent flag injection.") + + def save(self, records: List[MemoryRecord]) -> None: + """Saves a list of MemoryRecords conforming to the StorageBackend protocol.""" + for record in records: + value_str = str(record.value) + + # Generate a persistent deterministic hash key using hashlib MD5 + hash_suffix = hashlib.md5(value_str.encode('utf-8')).hexdigest()[:12] + key = f"memory_{hash_suffix}" + + # Scope memories using config metadata or default category + category = record.metadata.get("agent_id", "default") + self._validate_inputs(category) + + # Prepare payload + payload = { + "key": key, + "value": value_str, + "category": category, + "metadata": record.metadata + } + + # Call the subprocess using '--db' flag per Mimir CLI docs + try: + cmd = [self.mimir_path, "--db", self.db_path, "store", json.dumps(payload)] + subprocess.run(cmd, check=True, capture_output=True, text=True) + logger.info(f"Successfully stored memory with key: {key}") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to store memory in Mimir: {e.stderr}") + raise e - def search(self, query: str, limit: int = 3, agent_id: Optional[str] = None) -> List[Dict[str, Any]]: - category = agent_id if agent_id else "default" + def search(self, query: str, limit: int = 3, scope_prefix: Optional[str] = None) -> List[Tuple[MemoryRecord, float]]: + """Searches memories and returns a list of (MemoryRecord, score) tuples.""" + category = scope_prefix if scope_prefix else "default" + self._validate_inputs(category, query) try: cmd = [ @@ -83,20 +112,16 @@ def search(self, query: str, limit: int = 3, agent_id: Optional[str] = None) -> raw_results = json.loads(result.stdout) formatted_results = [] - # Map raw outputs to structured results with text, score, and metadata for res in raw_results: - # Fallback to empty dict or default scores if fields are missing dynamically content_text = res.get("value", res.get("text", "")) - score = res.get("score", 0.0) + score = float(res.get("score", 0.0)) meta = res.get("metadata", {}) - formatted_results.append({ - "text": content_text, - "score": score, - "metadata": meta - }) + # Construct official MemoryRecord instances + record = MemoryRecord(value=content_text, metadata=meta) + formatted_results.append((record, score)) return formatted_results except subprocess.CalledProcessError as e: logger.error(f"Search failed in Mimir: {e.stderr}") - return [] \ No newline at end of file + raise e \ No newline at end of file From 0b2c88079e75eb7cb9536469318aef4bdd37507a Mon Sep 17 00:00:00 2001 From: Jacopo Date: Fri, 19 Jun 2026 16:09:04 +0200 Subject: [PATCH 12/18] Enhance MimirStorage stability with direct imports and subprocess timeouts --- .../crewai/memory/storage/mimir_storage.py | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index 2ed9ae11f7..42e94fd436 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -7,19 +7,9 @@ import re from typing import List, Dict, Any, Optional, Tuple -# Import standard CrewAI MemoryRecord structure from crewai.memory.storage.backend import StorageBackend -# Note: Ensure MemoryRecord is imported correctly based on CrewAI structure -# mapping fields: value/text -> record.value, metadata -> record.metadata -try: - from crewai.memory.storage.interface import MemoryRecord -except ImportError: - # Fallback/Mock definition if import path varies in local branch environment - from dataclasses import dataclass, field - @dataclass - class MemoryRecord: - value: Any - metadata: Dict[str, Any] = field(default_factory=dict) +# CodeRabbit Fix: Direct import to fail-fast and avoid masking integration issues +from crewai.memory.storage.interface import MemoryRecord logger = logging.getLogger(__name__) @@ -84,11 +74,14 @@ def save(self, records: List[MemoryRecord]) -> None: "metadata": record.metadata } - # Call the subprocess using '--db' flag per Mimir CLI docs + # Call the subprocess using '--db' flag per Mimir CLI docs (with 10s timeout) try: cmd = [self.mimir_path, "--db", self.db_path, "store", json.dumps(payload)] - subprocess.run(cmd, check=True, capture_output=True, text=True) + subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=10) logger.info(f"Successfully stored memory with key: {key}") + except subprocess.TimeoutExpired as te: + logger.error(f"Mimir store operation timed out: {te}") + raise te except subprocess.CalledProcessError as e: logger.error(f"Failed to store memory in Mimir: {e.stderr}") raise e @@ -98,6 +91,7 @@ def search(self, query: str, limit: int = 3, scope_prefix: Optional[str] = None) category = scope_prefix if scope_prefix else "default" self._validate_inputs(category, query) + # Call the subprocess to query Mimir CLI (with 10s timeout) try: cmd = [ self.mimir_path, @@ -107,7 +101,7 @@ def search(self, query: str, limit: int = 3, scope_prefix: Optional[str] = None) "--limit", str(limit), "--category", category ] - result = subprocess.run(cmd, check=True, capture_output=True, text=True) + result = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=10) raw_results = json.loads(result.stdout) formatted_results = [] @@ -122,6 +116,9 @@ def search(self, query: str, limit: int = 3, scope_prefix: Optional[str] = None) formatted_results.append((record, score)) return formatted_results + except subprocess.TimeoutExpired as te: + logger.error(f"Mimir search operation timed out: {te}") + raise te except subprocess.CalledProcessError as e: logger.error(f"Search failed in Mimir: {e.stderr}") raise e \ No newline at end of file From cd0fbaff53ce6064ddf5f9cbcd02cc159dc7969a Mon Sep 17 00:00:00 2001 From: Jacopo Date: Sat, 20 Jun 2026 00:45:38 +0200 Subject: [PATCH 13/18] fix(task): centralize token tracking and preserve usage during guardrail retries --- lib/crewai/src/crewai/task.py | 39 ++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/lib/crewai/src/crewai/task.py b/lib/crewai/src/crewai/task.py index c63cfe8666..736221065d 100644 --- a/lib/crewai/src/crewai/task.py +++ b/lib/crewai/src/crewai/task.py @@ -650,7 +650,7 @@ async def _aexecute_core( raise Exception( f"The task '{self.description}' has no agent assigned, therefore it can't be executed directly and should be executed in a Crew using a specific process that support that, like hierarchical." ) - + tokens_before = self._get_agent_token_usage(agent) self.prompt_context = context tools = tools or self.tools or [] @@ -688,6 +688,9 @@ async def _aexecute_core( raw = result pydantic_output, json_output = None, None + tokens_after = self._get_agent_token_usage(agent) + token_delta = self._calculate_token_delta(tokens_before, tokens_after) + task_output = TaskOutput( name=self.name or self.description, description=self.description, @@ -698,6 +701,7 @@ async def _aexecute_core( agent=agent.role, output_format=self._get_output_format(), messages=agent.last_messages, # type: ignore[attr-defined] + token_usage=token_delta, ) if self._guardrails: @@ -775,7 +779,7 @@ def _execute_core( raise Exception( f"The task '{self.description}' has no agent assigned, therefore it can't be executed directly and should be executed in a Crew using a specific process that support that, like hierarchical." ) - + tokens_before = self._get_agent_token_usage(agent) self.prompt_context = context tools = tools or self.tools or [] @@ -813,6 +817,9 @@ def _execute_core( raw = result pydantic_output, json_output = None, None + tokens_after = self._get_agent_token_usage(agent) + token_delta = self._calculate_token_delta(tokens_before, tokens_after) + task_output = TaskOutput( name=self.name or self.description, description=self.description, @@ -823,6 +830,7 @@ def _execute_core( agent=agent.role, output_format=self._get_output_format(), messages=agent.last_messages, # type: ignore[attr-defined] + token_usage=token_delta, ) if self._guardrails: @@ -1149,7 +1157,22 @@ async def _aexport_output( pydantic_output, json_output = self._unpack_model_output(model_output) return pydantic_output, json_output - + + def _get_agent_token_usage(self, agent: BaseAgent) -> Any: + """Capture the current snapshot of tokens consumed by the agent's LLM.""" + if hasattr(agent, "llm") and hasattr(agent.llm, "token_usage"): + return shallow_copy(agent.llm.token_usage) + return None + + def _calculate_token_delta(self, before: Any, after: Any) -> Any: + """Calculate the difference in token consumption between before and after execution.""" + if before is None or after is None: + return after + try: + return after - before + except Exception: + return after + @staticmethod def _unpack_model_output( model_output: dict[str, Any] | BaseModel | str, @@ -1262,6 +1285,7 @@ def _invoke_guardrail_function( max_attempts = self.guardrail_max_retries + 1 for attempt in range(max_attempts): + current_token_usage = getattr(task_output, 'token_usage', None) guardrail_result = process_guardrail( output=task_output, guardrail=guardrail, @@ -1286,7 +1310,8 @@ def _invoke_guardrail_function( task_output.json_dict = json_output elif isinstance(guardrail_result.result, TaskOutput): task_output = guardrail_result.result - + if getattr(task_output, 'token_usage', None) is None and current_token_usage: + task_output.token_usage = current_token_usage return task_output if attempt >= self.guardrail_max_retries: @@ -1348,6 +1373,7 @@ def _invoke_guardrail_function( agent=agent.role, output_format=self._get_output_format(), messages=agent.last_messages, # type: ignore[attr-defined] + token_usage=current_token_usage, ) return task_output @@ -1372,6 +1398,7 @@ async def _ainvoke_guardrail_function( max_attempts = self.guardrail_max_retries + 1 for attempt in range(max_attempts): + current_token_usage = getattr(task_output, 'token_usage', None) guardrail_result = process_guardrail( output=task_output, guardrail=guardrail, @@ -1396,7 +1423,8 @@ async def _ainvoke_guardrail_function( task_output.json_dict = json_output elif isinstance(guardrail_result.result, TaskOutput): task_output = guardrail_result.result - + if getattr(task_output, 'token_usage', None) is None and current_token_usage: + task_output.token_usage = current_token_usage return task_output if attempt >= self.guardrail_max_retries: @@ -1458,6 +1486,7 @@ async def _ainvoke_guardrail_function( agent=agent.role, output_format=self._get_output_format(), messages=agent.last_messages, # type: ignore[attr-defined] + token_usage=current_token_usage, ) return task_output From bba80150e76c5ac5a707a7c7cbe785f452278e74 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Sat, 20 Jun 2026 01:09:46 +0200 Subject: [PATCH 14/18] fix(task): address token delta calculation for dict types and explicit guardrail none checks --- lib/crewai/src/crewai/task.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/crewai/src/crewai/task.py b/lib/crewai/src/crewai/task.py index 736221065d..826dc80fa3 100644 --- a/lib/crewai/src/crewai/task.py +++ b/lib/crewai/src/crewai/task.py @@ -1165,9 +1165,17 @@ def _get_agent_token_usage(self, agent: BaseAgent) -> Any: return None def _calculate_token_delta(self, before: Any, after: Any) -> Any: - """Calculate the difference in token consumption between before and after execution.""" - if before is None or after is None: - return after + """Calculate the delta of token usage.""" + if isinstance(before, dict) and isinstance(after, dict): + delta_dict = {} + for key, after_val in after.items(): + before_val = before.get(key, 0) + if isinstance(after_val, (int, float)) and isinstance(before_val, (int, float)): + delta_dict[key] = after_val - before_val + else: + delta_dict[key] = after_val + return delta_dict + try: return after - before except Exception: @@ -1310,7 +1318,7 @@ def _invoke_guardrail_function( task_output.json_dict = json_output elif isinstance(guardrail_result.result, TaskOutput): task_output = guardrail_result.result - if getattr(task_output, 'token_usage', None) is None and current_token_usage: + if getattr(task_output, 'token_usage', None) is None and current_token_usage is not None: task_output.token_usage = current_token_usage return task_output @@ -1423,8 +1431,8 @@ async def _ainvoke_guardrail_function( task_output.json_dict = json_output elif isinstance(guardrail_result.result, TaskOutput): task_output = guardrail_result.result - if getattr(task_output, 'token_usage', None) is None and current_token_usage: - task_output.token_usage = current_token_usage + if getattr(task_output, 'token_usage', None) is None and current_token_usage is not None: + task_output.token_usage = current_token_usage return task_output if attempt >= self.guardrail_max_retries: From 539d2f95fb155927b74128d7e7f3e0ff865c676e Mon Sep 17 00:00:00 2001 From: Jacopo Date: Sat, 20 Jun 2026 01:14:01 +0200 Subject: [PATCH 15/18] fix(task,memory): address token tracking lifecycle and sync storage layer signatures --- .../src/crewai/memory/storage/factory.py | 7 +++++- .../crewai/memory/storage/mimir_storage.py | 23 ++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/factory.py b/lib/crewai/src/crewai/memory/storage/factory.py index fc465d42b2..1964daf671 100644 --- a/lib/crewai/src/crewai/memory/storage/factory.py +++ b/lib/crewai/src/crewai/memory/storage/factory.py @@ -53,7 +53,12 @@ def resolve_memory_storage(spec: str, config: Optional[dict] = None) -> StorageB # First, respect user-registered custom factories if available factory = _factory if factory is not None: - custom_backend = factory(spec) + try: + # Try to pass config if the custom factory supports it + custom_backend = factory(spec, config=config) # type: ignore + except TypeError: + custom_backend = factory(spec) + if custom_backend is not None: return custom_backend diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index 42e94fd436..6b53795000 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -86,10 +86,24 @@ def save(self, records: List[MemoryRecord]) -> None: logger.error(f"Failed to store memory in Mimir: {e.stderr}") raise e - def search(self, query: str, limit: int = 3, scope_prefix: Optional[str] = None) -> List[Tuple[MemoryRecord, float]]: + def search( + self, + query: Any, + limit: int = 3, + scope_prefix: Optional[str] = None, + categories: Optional[List[str]] = None, + min_score: Optional[float] = None, + **kwargs + ) -> List[Tuple[MemoryRecord, float]]: """Searches memories and returns a list of (MemoryRecord, score) tuples.""" + query_str = query if isinstance(query, str) else str(query) + category = scope_prefix if scope_prefix else "default" - self._validate_inputs(category, query) + + if categories and len(categories) > 0: + category = categories[0] + + self._validate_inputs(category, query_str) # Call the subprocess to query Mimir CLI (with 10s timeout) try: @@ -97,7 +111,7 @@ def search(self, query: str, limit: int = 3, scope_prefix: Optional[str] = None) self.mimir_path, "--db", self.db_path, "search", - query, + query_str, "--limit", str(limit), "--category", category ] @@ -111,6 +125,9 @@ def search(self, query: str, limit: int = 3, scope_prefix: Optional[str] = None) score = float(res.get("score", 0.0)) meta = res.get("metadata", {}) + if min_score is not None and score < min_score: + continue + # Construct official MemoryRecord instances record = MemoryRecord(value=content_text, metadata=meta) formatted_results.append((record, score)) From d5c346880dbd90d7d143c04c74b005e1bee6c7b6 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Sat, 20 Jun 2026 01:23:04 +0200 Subject: [PATCH 16/18] fix(memory): align search limit and min_score protocol defaults in mimir storage --- .../crewai/memory/storage/mimir_storage.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index 6b53795000..2523f89070 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -89,30 +89,32 @@ def save(self, records: List[MemoryRecord]) -> None: def search( self, query: Any, - limit: int = 3, + limit: Optional[int] = None, scope_prefix: Optional[str] = None, categories: Optional[List[str]] = None, - min_score: Optional[float] = None, + min_score: Optional[float] = None, + metadata_filter: Optional[Dict[str, Any]] = None, **kwargs ) -> List[Tuple[MemoryRecord, float]]: """Searches memories and returns a list of (MemoryRecord, score) tuples.""" query_str = query if isinstance(query, str) else str(query) + actual_limit = limit if limit is not None else 3 + category = scope_prefix if scope_prefix else "default" if categories and len(categories) > 0: - category = categories[0] + category = categories[0] self._validate_inputs(category, query_str) - # Call the subprocess to query Mimir CLI (with 10s timeout) try: cmd = [ self.mimir_path, "--db", self.db_path, "search", query_str, - "--limit", str(limit), + "--limit", str(actual_limit), "--category", category ] result = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=10) @@ -128,6 +130,15 @@ def search( if min_score is not None and score < min_score: continue + if metadata_filter: + match = True + for k, v in metadata_filter.items(): + if meta.get(k) != v: + match = False + break + if not match: + continue + # Construct official MemoryRecord instances record = MemoryRecord(value=content_text, metadata=meta) formatted_results.append((record, score)) From 030fff6b1c9d99d993d46a85e9a0d9541ef666af Mon Sep 17 00:00:00 2001 From: Jacopo Date: Sun, 21 Jun 2026 11:00:15 +0200 Subject: [PATCH 17/18] feat: implement deterministic tool permission gating for agents (#6232) --- .../crewai/agents/agent_builder/base_agent.py | 10 +++++- .../crewai/memory/storage/mimir_storage.py | 2 +- lib/crewai/src/crewai/tools/base_tool.py | 11 ++++++- lib/crewai/src/crewai/tools/tool_usage.py | 31 +++++++++++++++++-- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py index ded6bb40ab..add9c7a2ff 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py @@ -6,7 +6,7 @@ from hashlib import md5 from pathlib import Path import re -from typing import TYPE_CHECKING, Annotated, Any, Final, Literal +from typing import TYPE_CHECKING, Annotated, Any, Final, List, Literal, Optional import uuid from pydantic import ( @@ -248,6 +248,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): Set private attributes. """ + capabilities: Optional[List[str]] = Field( + default=None, + description="List of deterministic capabilities or permissions assigned to the agent." + ) + require_approval_for: Optional[List[str]] = Field( + default=None, + description="List of tools or capabilities that require manual human approval before execution." + ) entity_type: Literal["agent"] = "agent" __hash__ = object.__hash__ diff --git a/lib/crewai/src/crewai/memory/storage/mimir_storage.py b/lib/crewai/src/crewai/memory/storage/mimir_storage.py index 2523f89070..dd13cbefe0 100644 --- a/lib/crewai/src/crewai/memory/storage/mimir_storage.py +++ b/lib/crewai/src/crewai/memory/storage/mimir_storage.py @@ -9,7 +9,7 @@ from crewai.memory.storage.backend import StorageBackend # CodeRabbit Fix: Direct import to fail-fast and avoid masking integration issues -from crewai.memory.storage.interface import MemoryRecord +from crewai.memory.storage.interface import MemoryRecord # type: ignore logger = logging.getLogger(__name__) diff --git a/lib/crewai/src/crewai/tools/base_tool.py b/lib/crewai/src/crewai/tools/base_tool.py index 31c5009bd2..452bf9f91f 100644 --- a/lib/crewai/src/crewai/tools/base_tool.py +++ b/lib/crewai/src/crewai/tools/base_tool.py @@ -10,6 +10,7 @@ from typing import ( Any, Generic, + Optional, ParamSpec, TypeVar, overload, @@ -149,7 +150,15 @@ def _validate_tool(value: Any, nxt: Any) -> Any: validate_default=True, description="The schema for the arguments that the tool accepts.", ) - + args_schema: type[PydanticBaseModel] = Field( + default=_ArgsSchemaPlaceholder, + validate_default=True, + description="The schema for the arguments that the tool accepts.", + ) + required_capability: Optional[str] = Field( + default=None, + description="The specific capability required to execute this tool." + ) @field_serializer("args_schema", when_used="json") def _serialize_args_schema( self, schema: type[PydanticBaseModel] | None diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py index b349218396..d3bd349d32 100644 --- a/lib/crewai/src/crewai/tools/tool_usage.py +++ b/lib/crewai/src/crewai/tools/tool_usage.py @@ -836,11 +836,36 @@ def _tool_calling( ) -> ToolCalling | InstructorToolCalling | ToolUsageError: try: try: - return self._original_tool_calling(tool_string, raise_error=True) + tool_calling = self._original_tool_calling(tool_string, raise_error=True) except Exception: if self.function_calling_llm: - return self._function_calling(tool_string) - return self._original_tool_calling(tool_string) + tool_calling = self._function_calling(tool_string) + else: + tool_calling = self._original_tool_calling(tool_string) + if tool_calling and not isinstance(tool_calling, ToolUsageError): + tool = self._select_tool(tool_calling.tool_name) + + if tool and hasattr(tool, "required_capability"): + required_cap = tool.required_capability + if required_cap: + agent_caps = getattr(self.agent, "capabilities", None) or [] + # Block execution if the agent lacks the required capability + if required_cap not in agent_caps: + error_msg = f"Security Violation: Agent '{self.agent.role}' lacks the required capability '{required_cap}' to execute tool '{tool.name}'." + if self.agent and self.agent.verbose: + PRINTER.print(content=error_msg, color="red") + return ToolUsageError(error_msg) + + # Human-in-the-loop gating / Manual approval check + if tool and self.agent: + require_approval_for = getattr(self.agent, "require_approval_for", None) or [] + if tool.name in require_approval_for or (hasattr(tool, "required_capability") and tool.required_capability in require_approval_for): + if hasattr(self, "_ask_human_approval"): + approved = self._ask_human_approval(tool.name) + if not approved: + return ToolUsageError("Execution cancelled by human operator.") + return tool_calling + except Exception as e: self._run_attempts += 1 if self._run_attempts > self._max_parsing_attempts: From 6bfc9a8993b5b6eb61c22264f53031810a53eda0 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Sun, 21 Jun 2026 11:53:32 +0200 Subject: [PATCH 18/18] fix(security): propagate capability gating to structured tools --- lib/crewai/src/crewai/tools/base_tool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/crewai/src/crewai/tools/base_tool.py b/lib/crewai/src/crewai/tools/base_tool.py index 452bf9f91f..150e4192fa 100644 --- a/lib/crewai/src/crewai/tools/base_tool.py +++ b/lib/crewai/src/crewai/tools/base_tool.py @@ -385,6 +385,7 @@ def to_structured_tool(self) -> CrewStructuredTool: cache_function=self.cache_function, ) structured_tool._original_tool = self + setattr(structured_tool, "required_capability", self.required_capability) return structured_tool @classmethod