diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index a683b9967..8e0b6132a 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -8,12 +8,10 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "lancedb~=0.5.4", "pytube~=15.0.0", "requests~=2.32.5", "docker~=7.1.0", "crewai==1.9.3", - "lancedb~=0.5.4", "tiktoken~=0.8.0", "beautifulsoup4~=4.13.4", "python-docx~=1.2.0", diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index b4b3e2ecc..30e370072 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "mcp~=1.26.0", "uv~=0.9.13", "aiosqlite~=0.21.0", - "lancedb>=0.4.0", + "lancedb>=0.29.2", ] [project.urls] diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index 22b5a24ca..e121a9771 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -384,10 +384,10 @@ class Agent(BaseAgent): ) if unified_memory is not None: query = task.description - matches = unified_memory.recall(query, limit=10) + matches = unified_memory.recall(query, limit=5) if matches: memory = "Relevant memories:\n" + "\n".join( - f"- {m.record.content}" for m in matches + m.format() for m in matches ) if memory.strip() != "": task_prompt += self.i18n.slice("memory").format(memory=memory) @@ -622,10 +622,10 @@ class Agent(BaseAgent): ) if unified_memory is not None: query = task.description - matches = unified_memory.recall(query, limit=10) + matches = unified_memory.recall(query, limit=5) if matches: memory = "Relevant memories:\n" + "\n".join( - f"- {m.record.content}" for m in matches + m.format() for m in matches ) if memory.strip() != "": task_prompt += self.i18n.slice("memory").format(memory=memory) @@ -1811,11 +1811,11 @@ class Agent(BaseAgent): ), ) start_time = time.time() - matches = agent_memory.recall(formatted_messages, limit=10) + matches = agent_memory.recall(formatted_messages, limit=5) memory_block = "" if matches: memory_block = "Relevant memories:\n" + "\n".join( - f"- {m.record.content}" for m in matches + m.format() for m in matches ) if memory_block: formatted_messages += "\n\n" + self.i18n.slice("memory").format( diff --git a/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py b/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py index b36595ec9..1abfb6e5a 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py @@ -30,7 +30,7 @@ class CrewAgentExecutorMixin: memory = getattr(self.agent, "memory", None) or ( getattr(self.crew, "_memory", None) if self.crew else None ) - if memory is None or not self.task: + if memory is None or not self.task or getattr(memory, "_read_only", False): return if ( f"Action: {sanitize_tool_name('Delegate work to coworker')}" diff --git a/lib/crewai/src/crewai/cli/memory_tui.py b/lib/crewai/src/crewai/cli/memory_tui.py index 98576670d..9dd91a42c 100644 --- a/lib/crewai/src/crewai/cli/memory_tui.py +++ b/lib/crewai/src/crewai/cli/memory_tui.py @@ -290,13 +290,20 @@ class MemoryTUI(App[None]): if self._memory is None: panel.update(self._init_error or "No memory loaded.") return + display_limit = 1000 info = self._memory.info(path) self._last_scope_info = info - self._entries = self._memory.list_records(scope=path, limit=200) + self._entries = self._memory.list_records(scope=path, limit=display_limit) panel.update(_format_scope_info(info)) panel.border_title = "Detail" entry_list = self.query_one("#entry-list", OptionList) - entry_list.border_title = f"Entries ({len(self._entries)})" + capped = info.record_count > display_limit + count_label = ( + f"Entries (showing {display_limit} of {info.record_count} — display limit)" + if capped + else f"Entries ({len(self._entries)})" + ) + entry_list.border_title = count_label self._populate_entry_list() def on_option_list_option_highlighted( @@ -376,6 +383,11 @@ class MemoryTUI(App[None]): return info_lines: list[str] = [] + info_lines.append( + "[dim italic]Searched the full dataset" + + (f" within [bold]{scope}[/]" if scope else "") + + " using the recall flow (semantic + recency + importance).[/]\n" + ) if not self._custom_embedder: info_lines.append( "[dim italic]Note: Using default OpenAI embedder. " diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index 5b7725a3b..66b710890 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -599,8 +599,8 @@ class LiteAgent(FlowTrackable, BaseModel): ) def _save_to_memory(self, output_text: str) -> None: - """Extract discrete memories from the run and remember each. No-op if _memory is None.""" - if self._memory is None: + """Extract discrete memories from the run and remember each. No-op if _memory is None or read-only.""" + if self._memory is None or getattr(self._memory, "_read_only", False): return input_str = self._get_last_user_content() or "User request" try: diff --git a/lib/crewai/src/crewai/memory/memory_scope.py b/lib/crewai/src/crewai/memory/memory_scope.py index b828e3faf..705ec07de 100644 --- a/lib/crewai/src/crewai/memory/memory_scope.py +++ b/lib/crewai/src/crewai/memory/memory_scope.py @@ -145,7 +145,7 @@ class MemoryScope: class MemorySlice: - """View over multiple scopes: recall searches all, remember requires explicit scope unless read_only.""" + """View over multiple scopes: recall searches all, remember is a no-op when read_only.""" def __init__( self, @@ -160,7 +160,7 @@ class MemorySlice: memory: The underlying Memory instance. scopes: List of scope paths to include. categories: Optional category filter for recall. - read_only: If True, remember() raises PermissionError. + read_only: If True, remember() is a silent no-op. """ self._memory = memory self._scopes = [s.rstrip("/") or "/" for s in scopes] @@ -176,10 +176,10 @@ class MemorySlice: importance: float | None = None, source: str | None = None, private: bool = False, - ) -> MemoryRecord: - """Remember into an explicit scope. Required when read_only=False.""" + ) -> MemoryRecord | None: + """Remember into an explicit scope. No-op when read_only=True.""" if self._read_only: - raise PermissionError("This MemorySlice is read-only") + return None return self._memory.remember( content, scope=scope, diff --git a/lib/crewai/src/crewai/memory/storage/lancedb_storage.py b/lib/crewai/src/crewai/memory/storage/lancedb_storage.py index d40999985..e514edcac 100644 --- a/lib/crewai/src/crewai/memory/storage/lancedb_storage.py +++ b/lib/crewai/src/crewai/memory/storage/lancedb_storage.py @@ -53,6 +53,7 @@ class LanceDBStorage: path: str | Path | None = None, table_name: str = "memories", vector_dim: int | None = None, + compact_every: int = 100, ) -> None: """Initialize LanceDB storage. @@ -64,6 +65,10 @@ class LanceDBStorage: vector_dim: Dimensionality of the embedding vector. When ``None`` (default), the dimension is auto-detected from the existing table schema or from the first saved embedding. + compact_every: Number of ``save()`` calls between automatic + background compactions. Each ``save()`` creates one new + fragment file; compaction merges them, keeping query + performance consistent. Set to 0 to disable. """ if path is None: storage_dir = os.environ.get("CREWAI_STORAGE_DIR") @@ -78,6 +83,22 @@ class LanceDBStorage: self._table_name = table_name self._db = lancedb.connect(str(self._path)) + # On macOS and Linux the default per-process open-file limit is 256. + # A LanceDB table stores one file per fragment (one fragment per save() + # call by default). With hundreds of fragments, a single full-table + # scan opens all of them simultaneously, exhausting the limit. + # Raise it proactively so scans on large tables never hit OS error 24. + try: + import resource + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + if soft < 4096: + resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 4096), hard)) + except Exception: # noqa: S110 + pass # Windows or already at the max hard limit — safe to ignore + + self._compact_every = compact_every + self._save_count = 0 + # Get or create a shared write lock for this database path. resolved = str(self._path.resolve()) with LanceDBStorage._path_locks_guard: @@ -91,6 +112,11 @@ class LanceDBStorage: try: self._table: lancedb.table.Table | None = self._db.open_table(self._table_name) self._vector_dim: int = self._infer_dim_from_table(self._table) + # Best-effort: create the scope index if it doesn't exist yet. + self._ensure_scope_index() + # Compact in the background if the table has accumulated many + # fragments from previous runs (each save() creates one). + self._compact_if_needed() except Exception: self._table = None self._vector_dim = vector_dim or 0 # 0 = not yet known @@ -178,6 +204,56 @@ class LanceDBStorage: table.delete("id = '__schema_placeholder__'") return table + def _ensure_scope_index(self) -> None: + """Create a BTREE scalar index on the ``scope`` column if not present. + + A scalar index lets LanceDB skip a full table scan when filtering by + scope prefix, which is the hot path for ``list_records``, + ``get_scope_info``, and ``list_scopes``. The call is best-effort: + if the table is empty or the index already exists the exception is + swallowed silently. + """ + if self._table is None: + return + try: + self._table.create_scalar_index("scope", index_type="BTREE", replace=False) + except Exception: # noqa: S110 + pass # index already exists, table empty, or unsupported version + + # ------------------------------------------------------------------ + # Automatic background compaction + # ------------------------------------------------------------------ + + def _compact_if_needed(self) -> None: + """Spawn a background compaction on startup. + + Called whenever an existing table is opened so that fragments + accumulated in previous sessions are silently merged before the + first query. ``optimize()`` returns quickly when the table is + already compact, so the cost is negligible in the common case. + """ + if self._table is None or self._compact_every <= 0: + return + self._compact_async() + + def _compact_async(self) -> None: + """Fire-and-forget: compact the table in a daemon background thread.""" + threading.Thread( + target=self._compact_safe, + daemon=True, + name="lancedb-compact", + ).start() + + def _compact_safe(self) -> None: + """Run ``table.optimize()`` in a background thread, absorbing errors.""" + try: + if self._table is not None: + self._table.optimize() + # Refresh the scope index so new fragments are covered. + self._ensure_scope_index() + except Exception: + _logger.debug("LanceDB background compaction failed", exc_info=True) + def _ensure_table(self, vector_dim: int | None = None) -> lancedb.table.Table: """Return the table, creating it lazily if needed. @@ -239,6 +315,7 @@ class LanceDBStorage: if r.embedding and len(r.embedding) > 0: dim = len(r.embedding) break + is_new_table = self._table is None with self._write_lock: self._ensure_table(vector_dim=dim) rows = [self._record_to_row(r) for r in records] @@ -246,6 +323,13 @@ class LanceDBStorage: if r["vector"] is None or len(r["vector"]) != self._vector_dim: r["vector"] = [0.0] * self._vector_dim self._retry_write("add", rows) + # Create the scope index on the first save so it covers the initial dataset. + if is_new_table: + self._ensure_scope_index() + # Auto-compact every N saves so fragment files don't pile up. + self._save_count += 1 + if self._compact_every > 0 and self._save_count % self._compact_every == 0: + self._compact_async() def update(self, record: MemoryRecord) -> None: """Update a record by ID. Preserves created_at, updates last_accessed.""" @@ -261,6 +345,10 @@ class LanceDBStorage: def touch_records(self, record_ids: list[str]) -> None: """Update last_accessed to now for the given record IDs. + Uses a single batch ``table.update()`` call instead of N + delete-and-re-add cycles, which is both faster and avoids + unnecessary write amplification. + Args: record_ids: IDs of records to touch. """ @@ -268,25 +356,20 @@ class LanceDBStorage: return with self._write_lock: now = datetime.utcnow().isoformat() - for rid in record_ids: - safe_id = str(rid).replace("'", "''") - rows = ( - self._table.search([0.0] * self._vector_dim) - .where(f"id = '{safe_id}'") - .limit(1) - .to_list() - ) - if rows: - rows[0]["last_accessed"] = now - self._retry_write("delete", f"id = '{safe_id}'") - self._retry_write("add", [rows[0]]) + safe_ids = [str(rid).replace("'", "''") for rid in record_ids] + ids_expr = ", ".join(f"'{rid}'" for rid in safe_ids) + self._retry_write( + "update", + where=f"id IN ({ids_expr})", + values={"last_accessed": now}, + ) def get_record(self, record_id: str) -> MemoryRecord | None: """Return a single record by ID, or None if not found.""" if self._table is None: return None safe_id = str(record_id).replace("'", "''") - rows = self._table.search([0.0] * self._vector_dim).where(f"id = '{safe_id}'").limit(1).to_list() + rows = self._table.search().where(f"id = '{safe_id}'").limit(1).to_list() if not rows: return None return self._row_to_record(rows[0]) @@ -374,13 +457,31 @@ class LanceDBStorage: self._retry_write("delete", where_expr) return before - self._table.count_rows() - def _scan_rows(self, scope_prefix: str | None = None, limit: int = _SCAN_ROWS_LIMIT) -> list[dict[str, Any]]: - """Scan rows optionally filtered by scope prefix.""" + def _scan_rows( + self, + scope_prefix: str | None = None, + limit: int = _SCAN_ROWS_LIMIT, + columns: list[str] | None = None, + ) -> list[dict[str, Any]]: + """Scan rows optionally filtered by scope prefix. + + Uses a full table scan (no vector query) so the limit is applied after + the scope filter, not to ANN candidates before filtering. + + Args: + scope_prefix: Optional scope path prefix to filter by. + limit: Maximum number of rows to return (applied after filtering). + columns: Optional list of column names to fetch. Pass only the + columns you need for metadata operations to avoid reading the + heavy ``vector`` column unnecessarily. + """ if self._table is None: return [] - q = self._table.search([0.0] * self._vector_dim) + q = self._table.search() if scope_prefix is not None and scope_prefix.strip("/"): q = q.where(f"scope LIKE '{scope_prefix.rstrip('/')}%'") + if columns is not None: + q = q.select(columns) return q.limit(limit).to_list() def list_records( @@ -406,7 +507,10 @@ class LanceDBStorage: prefix = scope if scope != "/" else "" if prefix and not prefix.startswith("/"): prefix = "/" + prefix - rows = self._scan_rows(prefix or None) + rows = self._scan_rows( + prefix or None, + columns=["scope", "categories_str", "created_at"], + ) if not rows: return ScopeInfo( path=scope or "/", @@ -453,7 +557,7 @@ class LanceDBStorage: def list_scopes(self, parent: str = "/") -> list[str]: parent = parent.rstrip("/") or "" prefix = (parent + "/") if parent else "/" - rows = self._scan_rows(prefix if prefix != "/" else None) + rows = self._scan_rows(prefix if prefix != "/" else None, columns=["scope"]) children: set[str] = set() for row in rows: sc = str(row.get("scope", "")) @@ -465,7 +569,7 @@ class LanceDBStorage: return sorted(children) def list_categories(self, scope_prefix: str | None = None) -> dict[str, int]: - rows = self._scan_rows(scope_prefix) + rows = self._scan_rows(scope_prefix, columns=["categories_str"]) counts: dict[str, int] = {} for row in rows: cat_str = row.get("categories_str") or "[]" @@ -498,6 +602,21 @@ class LanceDBStorage: if prefix: self._table.delete(f"scope >= '{prefix}' AND scope < '{prefix}/\uFFFF'") + def optimize(self) -> None: + """Compact the table synchronously and refresh the scope index. + + Under normal usage this is called automatically in the background + (every ``compact_every`` saves and on startup when the table is + fragmented). Call this explicitly only when you need the compaction + to be complete before the next operation — for example immediately + after a large bulk import, before a latency-sensitive recall. + It is a no-op if the table does not exist. + """ + if self._table is None: + return + self._table.optimize() + self._ensure_scope_index() + async def asave(self, records: list[MemoryRecord]) -> None: self.save(records) diff --git a/lib/crewai/src/crewai/memory/types.py b/lib/crewai/src/crewai/memory/types.py index e67ad163f..929e10092 100644 --- a/lib/crewai/src/crewai/memory/types.py +++ b/lib/crewai/src/crewai/memory/types.py @@ -87,6 +87,22 @@ class MemoryMatch(BaseModel): description="Information the system looked for but could not find.", ) + def format(self) -> str: + """Format this match as a human-readable string including metadata. + + Returns: + A multi-line string with score, content, categories, and non-empty + metadata fields. + """ + lines = [f"- (score={self.score:.2f}) {self.record.content}"] + if self.record.categories: + lines.append(f" categories: {', '.join(self.record.categories)}") + if self.record.metadata: + for key, value in self.record.metadata.items(): + if value is not None: + lines.append(f" {key}: {value}") + return "\n".join(lines) + class ScopeInfo(BaseModel): """Information about a scope in the memory hierarchy.""" @@ -291,7 +307,7 @@ def embed_text(embedder: Any, text: str) -> list[float]: return [] first = result[0] if hasattr(first, "tolist"): - return first.tolist() + return list(first.tolist()) if isinstance(first, list): return [float(x) for x in first] return list(first) diff --git a/lib/crewai/src/crewai/memory/unified_memory.py b/lib/crewai/src/crewai/memory/unified_memory.py index a15f77afd..531a91208 100644 --- a/lib/crewai/src/crewai/memory/unified_memory.py +++ b/lib/crewai/src/crewai/memory/unified_memory.py @@ -88,6 +88,10 @@ class Memory: # Queries shorter than this skip LLM analysis (saving ~1-3s). # Longer queries (full task descriptions) benefit from LLM distillation. query_analysis_threshold: int = 200, + # When True, all write operations (remember, remember_many) are silently + # skipped. Useful for sharing a read-only view of memory across agents + # without any of them persisting new memories. + read_only: bool = False, ) -> None: """Initialize Memory. @@ -107,7 +111,9 @@ class Memory: complex_query_threshold: For complex queries, explore deeper below this confidence. exploration_budget: Number of LLM-driven exploration rounds during deep recall. query_analysis_threshold: Queries shorter than this skip LLM analysis during deep recall. + read_only: If True, remember() and remember_many() are silent no-ops. """ + self._read_only = read_only self._config = MemoryConfig( recency_weight=recency_weight, semantic_weight=semantic_weight, @@ -130,10 +136,13 @@ class Memory: self._llm_instance: BaseLLM | None = None if isinstance(llm, str) else llm self._embedder_config: Any = embedder self._embedder_instance: Any = ( - embedder if (embedder is not None and not isinstance(embedder, dict)) else None + embedder + if (embedder is not None and not isinstance(embedder, dict)) + else None ) # Storage is initialized eagerly (local, no API key needed). + self._storage: StorageBackend if storage == "lancedb": self._storage = LanceDBStorage() elif isinstance(storage, str): @@ -160,12 +169,17 @@ class Memory: from crewai.llm import LLM try: - self._llm_instance = LLM(model=self._llm_config) + model_name = ( + self._llm_config + if isinstance(self._llm_config, str) + else str(self._llm_config) + ) + self._llm_instance = LLM(model=model_name) except Exception as e: raise RuntimeError( f"Memory requires an LLM for analysis but initialization failed: {e}\n\n" "To fix this, do one of the following:\n" - ' - Set OPENAI_API_KEY for the default model (gpt-4o-mini)\n' + " - Set OPENAI_API_KEY for the default model (gpt-4o-mini)\n" ' - Pass a different model: Memory(llm="anthropic/claude-3-haiku-20240307")\n' ' - Pass any LLM instance: Memory(llm=LLM(model="your-model"))\n' " - To skip LLM analysis, pass all fields explicitly to remember()\n" @@ -182,7 +196,7 @@ class Memory: if isinstance(self._embedder_config, dict): from crewai.rag.embeddings.factory import build_embedder - self._embedder_instance = build_embedder(self._embedder_config) + self._embedder_instance = build_embedder(self._embedder_config) # type: ignore[call-overload] else: self._embedder_instance = _default_embedder() except Exception as e: @@ -317,7 +331,7 @@ class Memory: source: str | None = None, private: bool = False, agent_role: str | None = None, - ) -> MemoryRecord: + ) -> MemoryRecord | None: """Store a single item in memory (synchronous). Routes through the same serialized save pool as ``remember_many`` @@ -335,11 +349,13 @@ class Memory: agent_role: Optional agent role for event metadata. Returns: - The created MemoryRecord. + The created MemoryRecord, or None if this memory is read-only. Raises: Exception: On save failure (events emitted). """ + if self._read_only: + return None _source_type = "unified_memory" try: crewai_event_bus.emit( @@ -356,7 +372,13 @@ class Memory: # then immediately wait for the result. future = self._submit_save( self._encode_batch, - [content], scope, categories, metadata, importance, source, private, + [content], + scope, + categories, + metadata, + importance, + source, + private, ) records = future.result() record = records[0] if records else None @@ -420,13 +442,19 @@ class Memory: Returns: Empty list (records are not available until the background save completes). """ - if not contents: + if not contents or self._read_only: return [] self._submit_save( self._background_encode_batch, - contents, scope, categories, metadata, - importance, source, private, agent_role, + contents, + scope, + categories, + metadata, + importance, + source, + private, + agent_role, ) return [] @@ -566,14 +594,13 @@ class Memory: # Privacy filter if not include_private: raw = [ - (r, s) for r, s in raw + (r, s) + for r, s in raw if not r.private or r.source == source ] results = [] for r, s in raw: - composite, reasons = compute_composite_score( - r, s, self._config - ) + composite, reasons = compute_composite_score(r, s, self._config) results.append( MemoryMatch( record=r, @@ -739,7 +766,9 @@ class Memory: limit: Maximum number of records to return. offset: Number of records to skip (for pagination). """ - return self._storage.list_records(scope_prefix=scope, limit=limit, offset=offset) + return self._storage.list_records( + scope_prefix=scope, limit=limit, offset=offset + ) def info(self, path: str = "/") -> ScopeInfo: """Return scope info for path.""" @@ -781,7 +810,7 @@ class Memory: importance: float | None = None, source: str | None = None, private: bool = False, - ) -> MemoryRecord: + ) -> MemoryRecord | None: """Async remember: delegates to sync for now.""" return self.remember( content, diff --git a/lib/crewai/src/crewai/tools/memory_tools.py b/lib/crewai/src/crewai/tools/memory_tools.py index 5c98a9892..f088fef73 100644 --- a/lib/crewai/src/crewai/tools/memory_tools.py +++ b/lib/crewai/src/crewai/tools/memory_tools.py @@ -20,14 +20,6 @@ class RecallMemorySchema(BaseModel): "or multiple items to search for several things at once." ), ) - scope: str | None = Field( - default=None, - description="Optional scope to narrow the search (e.g. /project/alpha)", - ) - depth: str = Field( - default="shallow", - description="'shallow' for fast vector search, 'deep' for LLM-analyzed retrieval", - ) class RecallMemoryTool(BaseTool): @@ -41,32 +33,27 @@ class RecallMemoryTool(BaseTool): def _run( self, queries: list[str] | str, - scope: str | None = None, - depth: str = "shallow", **kwargs: Any, ) -> str: """Search memory for relevant information. Args: queries: One or more search queries (string or list of strings). - scope: Optional scope prefix to narrow the search. - depth: "shallow" for fast vector search, "deep" for LLM-analyzed retrieval. Returns: Formatted string of matching memories, or a message if none found. """ if isinstance(queries, str): queries = [queries] - actual_depth = depth if depth in ("shallow", "deep") else "shallow" all_lines: list[str] = [] seen_ids: set[str] = set() for query in queries: - matches = self.memory.recall(query, scope=scope, limit=5, depth=actual_depth) + matches = self.memory.recall(query) for m in matches: if m.record.id not in seen_ids: seen_ids.add(m.record.id) - all_lines.append(f"- (score={m.score:.2f}) {m.record.content}") + all_lines.append(m.format()) if not all_lines: return "No relevant memories found." @@ -117,20 +104,28 @@ class RememberTool(BaseTool): def create_memory_tools(memory: Any) -> list[BaseTool]: """Create Recall and Remember tools for the given memory instance. + When memory is read-only (``_read_only=True``), only the RecallMemoryTool + is returned — the RememberTool is omitted so agents are never offered a + save capability they cannot use. + Args: memory: A Memory, MemoryScope, or MemorySlice instance. Returns: - List containing a RecallMemoryTool and a RememberTool. + List containing a RecallMemoryTool and, if not read-only, a RememberTool. """ i18n = get_i18n() - return [ + tools: list[BaseTool] = [ RecallMemoryTool( memory=memory, description=i18n.tools("recall_memory"), ), - RememberTool( - memory=memory, - description=i18n.tools("save_to_memory"), - ), ] + if not getattr(memory, "_read_only", False): + tools.append( + RememberTool( + memory=memory, + description=i18n.tools("save_to_memory"), + ) + ) + return tools diff --git a/lib/crewai/tests/agents/test_lite_agent.py b/lib/crewai/tests/agents/test_lite_agent.py index 761a12b23..c99b5c534 100644 --- a/lib/crewai/tests/agents/test_lite_agent.py +++ b/lib/crewai/tests/agents/test_lite_agent.py @@ -1136,6 +1136,7 @@ def test_lite_agent_memory_instance_recall_and_save_called(): successful_requests=1, ) mock_memory = Mock() + mock_memory._read_only = False mock_memory.recall.return_value = [] mock_memory.extract_memories.return_value = ["Fact one.", "Fact two."] diff --git a/lib/crewai/tests/memory/test_unified_memory.py b/lib/crewai/tests/memory/test_unified_memory.py index 5b25b8077..26e2a1929 100644 --- a/lib/crewai/tests/memory/test_unified_memory.py +++ b/lib/crewai/tests/memory/test_unified_memory.py @@ -218,14 +218,15 @@ def test_memory_slice_recall(tmp_path: Path, mock_embedder: MagicMock) -> None: assert isinstance(matches, list) -def test_memory_slice_remember_raises_when_read_only(tmp_path: Path, mock_embedder: MagicMock) -> None: +def test_memory_slice_remember_is_noop_when_read_only(tmp_path: Path, mock_embedder: MagicMock) -> None: from crewai.memory.unified_memory import Memory from crewai.memory.memory_scope import MemorySlice mem = Memory(storage=str(tmp_path / "db7"), llm=MagicMock(), embedder=mock_embedder) sl = MemorySlice(mem, ["/a"], read_only=True) - with pytest.raises(PermissionError): - sl.remember("x", scope="/a") + result = sl.remember("x", scope="/a") + assert result is None + assert mem.list_records() == [] # --- Flow memory --- @@ -318,6 +319,7 @@ def test_executor_save_to_memory_calls_extract_then_remember_per_item() -> None: from crewai.agents.parser import AgentFinish mock_memory = MagicMock() + mock_memory._read_only = False mock_memory.extract_memories.return_value = ["Fact A.", "Fact B."] mock_agent = MagicMock() @@ -358,6 +360,7 @@ def test_executor_save_to_memory_skips_delegation_output() -> None: from crewai.utilities.string_utils import sanitize_tool_name mock_memory = MagicMock() + mock_memory._read_only = False mock_agent = MagicMock() mock_agent.memory = mock_memory mock_agent._logger = MagicMock() diff --git a/uv.lock b/uv.lock index ea4af006f..dba6ab30c 100644 --- a/uv.lock +++ b/uv.lock @@ -1204,7 +1204,7 @@ requires-dist = [ { name = "json-repair", specifier = "~=0.25.2" }, { name = "json5", specifier = "~=0.10.0" }, { name = "jsonref", specifier = "~=1.1.0" }, - { name = "lancedb", specifier = ">=0.4.0" }, + { name = "lancedb", specifier = ">=0.29.2" }, { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.74.9,<3" }, { name = "mcp", specifier = "~=1.26.0" }, { name = "mem0ai", marker = "extra == 'mem0'", specifier = "~=0.1.94" }, @@ -1286,7 +1286,6 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "crewai" }, { name = "docker" }, - { name = "lancedb" }, { name = "pymupdf" }, { name = "python-docx" }, { name = "pytube" }, @@ -1428,7 +1427,6 @@ requires-dist = [ { name = "firecrawl-py", marker = "extra == 'firecrawl-py'", specifier = ">=1.8.0" }, { name = "gitpython", marker = "extra == 'github'", specifier = "==3.1.38" }, { name = "hyperbrowser", marker = "extra == 'hyperbrowser'", specifier = ">=0.18.0" }, - { name = "lancedb", specifier = "~=0.5.4" }, { name = "langchain-apify", marker = "extra == 'apify'", specifier = ">=0.1.2,<1.0.0" }, { name = "linkup-sdk", marker = "extra == 'linkup-sdk'", specifier = ">=0.2.2" }, { name = "lxml", marker = "extra == 'rag'", specifier = ">=5.3.0,<5.4.0" }, @@ -3226,27 +3224,54 @@ wheels = [ ] [[package]] -name = "lancedb" -version = "0.5.7" +name = "lance-namespace" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lance-namespace-urllib3-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/c6/aec0d7752e15536564b50cf9a8926f0e5d7780aa3ab8ce8bca46daa55659/lance_namespace-0.5.2.tar.gz", hash = "sha256:566cc33091b5631793ab411f095d46c66391db0a62343cd6b4470265bb04d577", size = 10274, upload-time = "2026-02-20T03:14:31.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/3d/737c008d8fb2861e7ce260e2ffab0d5058eae41556181f80f1a1c3b52ef5/lance_namespace-0.5.2-py3-none-any.whl", hash = "sha256:6ccaf5649bf6ee6aa92eed9c535a114b7b4eb08e89f40426f58bc1466cbcffa3", size = 12087, upload-time = "2026-02-20T03:14:35.261Z" }, +] + +[[package]] +name = "lance-namespace-urllib3-client" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "cachetools" }, - { name = "click" }, - { name = "deprecation" }, - { name = "overrides" }, { name = "pydantic" }, - { name = "pylance" }, - { name = "pyyaml" }, - { name = "ratelimiter" }, - { name = "requests" }, - { name = "retry" }, - { name = "semver" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'PyPy'" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/64/51622c93ec8c164483c83b68764e5e76e52286c0137a8247bc6a7fac25f4/lance_namespace_urllib3_client-0.5.2.tar.gz", hash = "sha256:8a3a238006e6eabc01fc9d385ac3de22ba933aef0ae8987558f3c3199c9b3799", size = 172578, upload-time = "2026-02-20T03:14:33.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/10/f86d994498b37f7f35d0b8c2f7626a16fe4cb1949b518c1e5d5052ecf95f/lance_namespace_urllib3_client-0.5.2-py3-none-any.whl", hash = "sha256:83cefb6fd6e5df0b99b5e866ee3d46300d375b75e8af32c27bc16fbf7c1a5978", size = 300351, upload-time = "2026-02-20T03:14:34.236Z" }, +] + +[[package]] +name = "lancedb" +version = "0.29.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "lance-namespace" }, + { name = "numpy" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "pyarrow" }, + { name = "pydantic" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/1b/f87a2b6420f6f55ea64e5f8f18f231450cc602a0854739bcf946cebc080a/lancedb-0.5.7.tar.gz", hash = "sha256:878914b493f91d09a77b14f1528104741f273234cbdd6671be705f447701fd51", size = 102890, upload-time = "2024-02-22T20:11:29.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/21/ecb191feff512640a59e17fe1737bd9c33970bc857c59a77fa61d5e314d9/lancedb-0.5.7-py3-none-any.whl", hash = "sha256:6169966f715ef530be545950e1aaf9f3f160967e4ba7456cd67c9f30f678095d", size = 115104, upload-time = "2024-02-22T20:11:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/fbb25946a234928958e016c5448343fd314bd601315f9587568321591a17/lancedb-0.29.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc1faf2e12addb9585569d0fb114ecc25ec3867e4e1aa6934e9343cfb5265ee4", size = 42341708, upload-time = "2026-02-09T06:21:31.677Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/d3a7b6d0237e343ad5b2afef2bdb99423746d5c3e882a9cab68dc041c2d0/lancedb-0.29.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fec19cfc52a5b9d98e060bd2f02a1c9df6a0bfd15b36021b6017327a41893a3", size = 44147347, upload-time = "2026-02-09T06:31:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/66/21/153a42294279c5b66d763f357808dde0899b71c5c8e41ad5ecbeeb8728df/lancedb-0.29.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:636939ab9225d435020ba17c231f5eaba15312a07813bcebcd71128204cc039f", size = 47186355, upload-time = "2026-02-09T06:34:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/f7041ae7d7730332b2754fe7adc2e0bd496f92bf526ac710b7eb3caf1d0a/lancedb-0.29.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f79b32083fcab139009db521d2f7fcd6afe4cca98a78c06c5940ff00a170cc1a", size = 44172354, upload-time = "2026-02-09T06:31:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/c152497c18cea0f36b523fc03b8e0a48be2b120276cc15a86d79b8b83cde/lancedb-0.29.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:991043a28c1f49f14df2479b554a95c759a85666dc58573cc86c1b9df05db794", size = 47228009, upload-time = "2026-02-09T06:34:40.872Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bd47bca59a87a88a4ca291a0718291422440750d84b34318048c70a537c2/lancedb-0.29.2-cp39-abi3-win_amd64.whl", hash = "sha256:101eb0ac018bb0b643dd9ea22065f6f2102e9d44c9ac58a197477ccbfbc0b9fa", size = 52028768, upload-time = "2026-02-09T07:00:02.272Z" }, ] [[package]] @@ -5414,15 +5439,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, ] -[[package]] -name = "py" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/ff/fec109ceb715d2a6b4c4a85a61af3b40c723a961e8828319fbcb15b868dc/py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", size = 207796, upload-time = "2021-11-04T17:17:01.377Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/f0/10642828a8dfb741e5f3fbaac830550a518a775c7fff6f04a007259b0548/py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", size = 98708, upload-time = "2021-11-04T17:17:00.152Z" }, -] - [[package]] name = "py-rust-stemmers" version = "0.1.5" @@ -5916,22 +5932,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pylance" -version = "0.9.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pyarrow" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/b8/15d4d380f0858dde46d42891776017e3bf9eb40129b3fe222637eecf8f43/pylance-0.9.18-cp38-abi3-macosx_10_15_x86_64.whl", hash = "sha256:fe2445d922c594d90e89111385106f6b152caab27996217db7bb4b8947eb0bea", size = 20319043, upload-time = "2024-02-19T07:36:11.206Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/69f927a215d415362300d14a50b3cbc6575fd640ca5e632d488e022d3af1/pylance-0.9.18-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:a2c424c50f5186edbbcc5a26f34063ed09d9a7390e28033395728ce02b5658f0", size = 18780426, upload-time = "2024-02-19T07:30:10.963Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b8/991e4544cfa21de2c7de5dd6bd8410df454fec5b374680fa96cd8698763b/pylance-0.9.18-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10af06edfde3e8451bf2251381d3980a0a164eab9d4c3d4dc8b6318969e958a6", size = 21584420, upload-time = "2024-02-19T07:32:30.283Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5e/ff80f31d995315790393cbe599565f55d03eb717654cfeb65b701803e887/pylance-0.9.18-cp38-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:d8bb9045d7163cc966b9fe34a917044192be37a90915475b77461e5b7d89e442", size = 19960982, upload-time = "2024-02-19T07:32:49.686Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e5/c0e0a6cad08ab86a9c0bce7e8caef8f666337bb7950e2ab151ea4f88242d/pylance-0.9.18-cp38-abi3-win_amd64.whl", hash = "sha256:5ea80b7bf70d992f3fe63bce2d2f064f742124c04eaedeb76baca408ded85a2c", size = 22089079, upload-time = "2024-02-19T07:42:43.262Z" }, -] - [[package]] name = "pylatexenc" version = "2.10" @@ -6629,15 +6629,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/fd/0d025466f0f84552634f2a94c018df34568fe55cc97184a6bb2c719c5b3a/rapidocr-3.6.0-py3-none-any.whl", hash = "sha256:d16b43872fc4dfa1e60996334dcd0dc3e3f1f64161e2332bc1873b9f65754e6b", size = 15067340, upload-time = "2026-01-28T14:45:04.271Z" }, ] -[[package]] -name = "ratelimiter" -version = "1.2.0.post0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/e0/b36010bddcf91444ff51179c076e4a09c513674a56758d7cfea4f6520e29/ratelimiter-1.2.0.post0.tar.gz", hash = "sha256:5c395dcabdbbde2e5178ef3f89b568a3066454a6ddc223b76473dac22f89b4f7", size = 9182, upload-time = "2017-12-12T00:33:38.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/80/2164fa1e863ad52cc8d870855fba0fbb51edd943edffd516d54b5f6f8ff8/ratelimiter-1.2.0.post0-py3-none-any.whl", hash = "sha256:a52be07bc0bb0b3674b4b304550f10c769bbb00fead3072e035904474259809f", size = 6642, upload-time = "2017-12-12T00:33:37.505Z" }, -] - [[package]] name = "redis" version = "7.1.0" @@ -6794,19 +6785,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] -[[package]] -name = "retry" -version = "0.9.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "decorator" }, - { name = "py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/72/75d0b85443fbc8d9f38d08d2b1b67cc184ce35280e4a3813cda2f445f3a4/retry-0.9.2.tar.gz", hash = "sha256:f8bfa8b99b69c4506d6f5bd3b0aabf77f98cdb17f3c9fc3f5ca820033336fba4", size = 6448, upload-time = "2016-05-11T13:58:51.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/0d/53aea75710af4528a25ed6837d71d117602b01946b307a3912cb3cfcbcba/retry-0.9.2-py2.py3-none-any.whl", hash = "sha256:ccddf89761fa2c726ab29391837d4327f819ea14d244c232a1d24c67a2f98606", size = 7986, upload-time = "2016-05-11T13:58:39.925Z" }, -] - [[package]] name = "rich" version = "14.3.2"