Files
crewAI/lib/crewai/src/crewai/memory/unified_memory.py
Lucas Gomide fb4b2afb77 feat: add native OpenTelemetry instrumentation
Open spans directly on the user's thread so that stdlib log records
emitted during hot paths like `Crew.kickoff`, `BaseTool.run`, and
`LLM.call` carry the active trace context and correlate with the
spans they belong to — a gap the previous metrics-only telemetry
could not close. Introduces a `crewai.telemetry.otel` module
exposing `operation` and `follows_from`, instruments the execution
hot paths, and propagates the active context across every
parallel-dispatch site. Depends only on `opentelemetry-api` so
provider and exporter choice stays with the host application per the
standard OTel library pattern; without an installed SDK the
`ProxyTracer` keeps everything as a NoOp.
2026-06-22 15:58:39 -03:00

1118 lines
42 KiB
Python

"""Unified Memory class: single intelligent memory with LLM analysis and pluggable storage."""
from __future__ import annotations
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import suppress
import contextvars
import copy
from datetime import datetime
import threading
import time
from typing import TYPE_CHECKING, Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, PlainValidator, PrivateAttr
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.llms.base_llm import BaseLLM
from crewai.memory.analyze import extract_memories_from_content
from crewai.memory.storage.backend import StorageBackend
from crewai.memory.types import (
MemoryConfig,
MemoryMatch,
MemoryRecord,
ScopeInfo,
compute_composite_score,
embed_text,
)
from crewai.memory.utils import join_scope_paths
from crewai.rag.embeddings.factory import build_embedder
from crewai.rag.embeddings.providers.openai.types import OpenAIProviderSpec
from crewai.telemetry.otel import operation
if TYPE_CHECKING:
from chromadb.utils.embedding_functions.openai_embedding_function import (
OpenAIEmbeddingFunction,
)
def _passthrough(v: Any) -> Any:
"""PlainValidator that accepts any value, bypassing strict union discrimination."""
return v
def _default_embedder() -> OpenAIEmbeddingFunction:
"""Build default OpenAI embedder for memory."""
spec: OpenAIProviderSpec = {"provider": "openai", "config": {}}
return build_embedder(spec)
def _non_streaming_analysis_llm(llm: Any) -> Any:
"""Return an isolated non-streaming LLM for internal memory analysis."""
if not isinstance(llm, BaseLLM):
return llm
try:
analysis_llm = copy.copy(llm)
except Exception:
try:
analysis_llm = llm.model_copy(deep=False)
except Exception:
return llm
with suppress(Exception):
analysis_llm.stream = False
return analysis_llm
class Memory(BaseModel):
"""Unified memory: standalone, LLM-analyzed, with intelligent recall flow.
Works without agent/crew. Uses LLM to infer scope, categories, importance on save.
Uses RecallFlow for adaptive-depth recall. Supports scope/slice views and
pluggable storage (LanceDB default).
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
memory_kind: Literal["memory"] = "memory"
llm: Annotated[BaseLLM | str, PlainValidator(_passthrough)] = Field(
default="gpt-5.4-mini",
description="LLM for analysis (model name or BaseLLM instance).",
)
storage: Annotated[StorageBackend | str, PlainValidator(_passthrough)] = Field(
default="lancedb",
description="Storage backend instance or path string.",
)
embedder: Any = Field(
default=None,
description="Embedding callable, provider config dict, or None for default OpenAI.",
)
recency_weight: float = Field(
default=0.3,
description="Weight for recency in the composite relevance score.",
)
semantic_weight: float = Field(
default=0.5,
description="Weight for semantic similarity in the composite relevance score.",
)
importance_weight: float = Field(
default=0.2,
description="Weight for importance in the composite relevance score.",
)
recency_half_life_days: int = Field(
default=30,
description="Recency score halves every N days (exponential decay).",
)
consolidation_threshold: float = Field(
default=0.85,
description="Similarity above which consolidation is triggered on save.",
)
consolidation_limit: int = Field(
default=5,
description="Max existing records to compare during consolidation.",
)
default_importance: float = Field(
default=0.5,
description="Default importance when not provided or inferred.",
)
confidence_threshold_high: float = Field(
default=0.8,
description="Recall confidence above which results are returned directly.",
)
confidence_threshold_low: float = Field(
default=0.5,
description="Recall confidence below which deeper exploration is triggered.",
)
complex_query_threshold: float = Field(
default=0.7,
description="For complex queries, explore deeper below this confidence.",
)
exploration_budget: int = Field(
default=1,
description="Number of LLM-driven exploration rounds during deep recall.",
)
query_analysis_threshold: int = Field(
default=200,
description="Queries shorter than this skip LLM analysis during deep recall.",
)
read_only: bool = Field(
default=False,
description="If True, remember() and remember_many() are silent no-ops.",
)
root_scope: str | None = Field(
default=None,
description=(
"Structural root scope prefix. When set, LLM-inferred or explicit scopes "
"are nested under this root. For example, a crew with root_scope='/crew/research' "
"will store memories at '/crew/research/<inferred_scope>'."
),
)
_config: MemoryConfig = PrivateAttr()
_llm_instance: BaseLLM | None = PrivateAttr(default=None)
_embedder_instance: Any = PrivateAttr(default=None)
_storage: StorageBackend = PrivateAttr()
_save_pool: ThreadPoolExecutor = PrivateAttr(
default_factory=lambda: ThreadPoolExecutor(
max_workers=1, thread_name_prefix="memory-save"
)
)
_pending_saves: list[Future[Any]] = PrivateAttr(default_factory=list)
_pending_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_reset_lock: Any = PrivateAttr(default_factory=threading.RLock)
def __deepcopy__(self, memo: dict[int, Any] | None = None) -> Memory:
"""Deepcopy that handles unpickleable private attrs (ThreadPoolExecutor, Lock)."""
import copy as _copy
cls = type(self)
new = cls.__new__(cls)
if memo is None:
memo = {}
memo[id(self)] = new
object.__setattr__(new, "__dict__", _copy.deepcopy(self.__dict__, memo))
object.__setattr__(
new, "__pydantic_fields_set__", _copy.copy(self.__pydantic_fields_set__)
)
object.__setattr__(
new, "__pydantic_extra__", _copy.deepcopy(self.__pydantic_extra__, memo)
)
private = {}
for k, v in (self.__pydantic_private__ or {}).items():
if k in {"_save_pool", "_pending_lock", "_reset_lock"}:
attr = self.__private_attributes__[k]
private[k] = attr.get_default()
elif isinstance(v, (ThreadPoolExecutor, threading.Lock)):
attr = self.__private_attributes__[k]
private[k] = attr.get_default()
else:
try:
private[k] = _copy.deepcopy(v, memo)
except Exception:
private[k] = v
object.__setattr__(new, "__pydantic_private__", private)
return new
def model_post_init(self, __context: Any) -> None:
"""Initialize runtime state from field values."""
self._config = MemoryConfig(
recency_weight=self.recency_weight,
semantic_weight=self.semantic_weight,
importance_weight=self.importance_weight,
recency_half_life_days=self.recency_half_life_days,
consolidation_threshold=self.consolidation_threshold,
consolidation_limit=self.consolidation_limit,
default_importance=self.default_importance,
confidence_threshold_high=self.confidence_threshold_high,
confidence_threshold_low=self.confidence_threshold_low,
complex_query_threshold=self.complex_query_threshold,
exploration_budget=self.exploration_budget,
query_analysis_threshold=self.query_analysis_threshold,
)
self._llm_instance = (
None if isinstance(self.llm, str) else _non_streaming_analysis_llm(self.llm)
)
self._embedder_instance = (
self.embedder
if (self.embedder is not None and not isinstance(self.embedder, dict))
else None
)
if isinstance(self.storage, str):
from crewai.memory.storage.factory import resolve_memory_storage
custom = resolve_memory_storage(self.storage)
if custom is not None:
self._storage = custom
elif self.storage == "qdrant-edge":
from crewai.memory.storage.qdrant_edge_storage import QdrantEdgeStorage
self._storage = QdrantEdgeStorage()
elif self.storage == "lancedb":
from crewai.memory.storage.lancedb_storage import LanceDBStorage
self._storage = LanceDBStorage()
else:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
self._storage = LanceDBStorage(path=self.storage)
else:
self._storage = self.storage
_MEMORY_DOCS_URL = "https://docs.crewai.com/concepts/memory"
@property
def _llm(self) -> BaseLLM:
"""Lazy LLM initialization -- only created when first needed."""
if self._llm_instance is None:
from crewai.llm import LLM
try:
model_name = self.llm if isinstance(self.llm, str) else str(self.llm)
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-5.4-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"
' and use depth="shallow" for recall.\n\n'
f"Docs: {self._MEMORY_DOCS_URL}"
) from e
return self._llm_instance
@property
def _embedder(self) -> Any:
"""Lazy embedder initialization -- only created when first needed."""
if self._embedder_instance is None:
try:
if isinstance(self.embedder, dict):
self._embedder_instance = build_embedder(self.embedder)
else:
self._embedder_instance = _default_embedder()
except Exception as e:
raise RuntimeError(
f"Memory requires an embedder for vector search but initialization failed: {e}\n\n"
"To fix this, do one of the following:\n"
" - Set OPENAI_API_KEY for the default embedder (text-embedding-3-large)\n"
' - Pass a different embedder: Memory(embedder={{"provider": "google", "config": {{...}}}})\n'
" - Pass a callable: Memory(embedder=my_embedding_function)\n\n"
f"Docs: {self._MEMORY_DOCS_URL}"
) from e
return self._embedder_instance
def _submit_save(self, fn: Any, *args: Any, **kwargs: Any) -> Future[Any]:
"""Submit a save operation to the background thread pool.
The future is tracked so that ``drain_writes()`` can wait for it.
If the pool has been shut down (e.g. after ``close()``), the save
runs synchronously as a fallback so late saves still succeed.
"""
with self._reset_lock:
ctx = contextvars.copy_context()
try:
future: Future[Any] = self._save_pool.submit(
ctx.run, fn, *args, **kwargs
)
except RuntimeError:
# Pool shut down -- run synchronously as fallback
future = Future()
try:
result = fn(*args, **kwargs)
future.set_result(result)
except Exception as exc:
future.set_exception(exc)
return future
with self._pending_lock:
self._pending_saves.append(future)
future.add_done_callback(self._on_save_done)
return future
def _on_save_done(self, future: Future[Any]) -> None:
"""Remove a completed future from the pending list and emit failure event if needed.
This callback must never raise -- it runs from the thread pool's
internal machinery during process shutdown when executors and the
event bus may already be closed.
"""
try:
with self._pending_lock:
try:
self._pending_saves.remove(future)
except ValueError:
pass # already removed
exc = future.exception()
if exc is not None:
crewai_event_bus.emit(
self,
MemorySaveFailedEvent(
value="background save",
error=str(exc),
source_type="unified_memory",
),
)
except Exception: # noqa: S110
pass # swallow everything during shutdown
def drain_writes(self) -> None:
"""Block until all pending background saves have completed.
Called automatically by ``recall()`` and should be called by the
crew at shutdown to ensure no saves are lost. Background save failures
are already reported through ``MemorySaveFailedEvent`` and should not
fail the task, crew, or flow that produced the output.
"""
with self._pending_lock:
pending = list(self._pending_saves)
for future in pending:
if future.cancelled():
continue
future.exception() # blocks until done without re-raising failures
def close(self) -> None:
"""Drain pending saves, flush storage, and shut down the background thread pool."""
self.drain_writes()
if hasattr(self._storage, "close"):
self._storage.close()
self._save_pool.shutdown(wait=True)
def _encode_batch(
self,
contents: list[str],
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
root_scope: str | None = None,
) -> list[MemoryRecord]:
"""Run the batch EncodingFlow for one or more items. No event emission.
This is the core encoding logic shared by ``remember()`` and
``remember_many()``. Events are managed by the calling method.
Args:
contents: List of text content to encode and store.
scope: Optional explicit scope (inner scope, nested under root_scope).
categories: Optional categories for all items.
metadata: Optional metadata for all items.
importance: Optional importance score for all items.
source: Optional source identifier for all items.
private: Whether items are private.
root_scope: Structural root scope prefix. LLM-inferred or explicit
scopes are nested under this root.
Returns:
List of created MemoryRecord instances.
"""
from crewai.memory.encoding_flow import EncodingFlow
flow = EncodingFlow(
storage=self._storage,
llm=self._llm,
embedder=self._embedder,
config=self._config,
)
items_input = [
{
"content": c,
"scope": scope,
"categories": categories,
"metadata": metadata,
"importance": importance,
"source": source,
"private": private,
"root_scope": root_scope,
}
for c in contents
]
flow.kickoff(inputs={"items": items_input})
return [
item.result_record
for item in flow.state.items
if not item.dropped and item.result_record is not None
]
def remember(
self,
content: str,
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
agent_role: str | None = None,
root_scope: str | None = None,
) -> MemoryRecord | None:
"""Store a single item in memory (synchronous).
Routes through the same serialized save pool as ``remember_many``
to prevent races, but blocks until the save completes so the caller
gets the ``MemoryRecord`` back immediately.
Args:
content: Text to remember.
scope: Optional scope path (inner scope); inferred if None.
categories: Optional categories; inferred if None.
metadata: Optional metadata; merged with LLM-extracted if inferred.
importance: Optional importance 0-1; inferred if None.
source: Optional provenance identifier (e.g. user ID, session ID).
private: If True, only visible to recall from the same source.
agent_role: Optional agent role for event metadata.
root_scope: Optional root scope override. If provided, this overrides
the instance-level root_scope for this call only.
Returns:
The created MemoryRecord, or None if this memory is read-only.
Raises:
Exception: On save failure (events emitted).
"""
if self.read_only:
return None
# Determine effective root_scope: per-call override takes precedence
effective_root = root_scope if root_scope is not None else self.root_scope
_source_type = "unified_memory"
try:
with operation(
"remember memory",
{"crewai.memory.source_type": _source_type},
):
crewai_event_bus.emit(
self,
MemorySaveStartedEvent(
value=content,
metadata=metadata,
source_type=_source_type,
),
)
start = time.perf_counter()
future = self._submit_save(
self._encode_batch,
[content],
scope,
categories,
metadata,
importance,
source,
private,
effective_root,
)
records = future.result()
record = records[0] if records else None
elapsed_ms = (time.perf_counter() - start) * 1000
crewai_event_bus.emit(
self,
MemorySaveCompletedEvent(
value=content,
metadata=metadata or {},
agent_role=agent_role,
save_time_ms=elapsed_ms,
source_type=_source_type,
),
)
return record
except Exception as e:
crewai_event_bus.emit(
self,
MemorySaveFailedEvent(
value=content,
metadata=metadata,
error=str(e),
source_type=_source_type,
),
)
raise
def remember_many(
self,
contents: list[str],
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
agent_role: str | None = None,
root_scope: str | None = None,
) -> list[MemoryRecord]:
"""Store multiple items in memory (non-blocking).
The encoding pipeline runs in a background thread. This method
returns immediately so the caller (e.g. agent) is not blocked.
A ``MemorySaveStartedEvent`` is emitted immediately; the
``MemorySaveCompletedEvent`` is emitted when the background
save finishes.
Any subsequent ``recall()`` call will automatically wait for
pending saves to complete before searching (read barrier).
Args:
contents: List of text items to remember.
scope: Optional scope (inner scope) applied to all items.
categories: Optional categories applied to all items.
metadata: Optional metadata applied to all items.
importance: Optional importance applied to all items.
source: Optional provenance identifier applied to all items.
private: Privacy flag applied to all items.
agent_role: Optional agent role for event metadata.
root_scope: Optional root scope override. If provided, this overrides
the instance-level root_scope for this call only.
Returns:
Empty list (records are not available until the background save completes).
"""
if not contents or self.read_only:
return []
# Determine effective root_scope: per-call override takes precedence
effective_root = root_scope if root_scope is not None else self.root_scope
self._submit_save(
self._background_encode_batch,
contents,
scope,
categories,
metadata,
importance,
source,
private,
agent_role,
effective_root,
)
return []
def _background_encode_batch(
self,
contents: list[str],
scope: str | None,
categories: list[str] | None,
metadata: dict[str, Any] | None,
importance: float | None,
source: str | None,
private: bool,
agent_role: str | None,
root_scope: str | None = None,
) -> list[MemoryRecord]:
"""Run the encoding pipeline in a background thread with event emission.
Both started and completed events are emitted here (in the background
thread) so they pair correctly on the event bus scope stack.
All ``emit`` calls are wrapped in try/except to handle the case where
the event bus shuts down before the background save finishes (e.g.
during process exit).
Args:
contents: List of text content to encode.
scope: Optional inner scope for all items.
categories: Optional categories for all items.
metadata: Optional metadata for all items.
importance: Optional importance for all items.
source: Optional source identifier for all items.
private: Whether items are private.
agent_role: Optional agent role for event metadata.
root_scope: Optional root scope prefix for hierarchical scoping.
Returns:
List of created MemoryRecord instances.
"""
try:
crewai_event_bus.emit(
self,
MemorySaveStartedEvent(
value=f"{len(contents)} memories (background)",
metadata=metadata,
source_type="unified_memory",
),
)
except RuntimeError:
pass # event bus shut down during process exit
try:
start = time.perf_counter()
records = self._encode_batch(
contents,
scope,
categories,
metadata,
importance,
source,
private,
root_scope,
)
elapsed_ms = (time.perf_counter() - start) * 1000
except RuntimeError as e:
# The encoding pipeline uses asyncio.run() -> to_thread() internally.
# If the process is shutting down, the default executor is closed and
# to_thread raises "cannot schedule new futures after shutdown".
# Silently abandon the save -- the process is exiting anyway.
# Any other RuntimeError must propagate so the save future's
# done-callback reports it via MemorySaveFailedEvent.
if "cannot schedule new futures" in str(e):
return []
raise
try:
crewai_event_bus.emit(
self,
MemorySaveCompletedEvent(
value=f"{len(records)} memories saved",
metadata=metadata or {},
agent_role=agent_role,
save_time_ms=elapsed_ms,
source_type="unified_memory",
),
)
except RuntimeError:
pass # event bus shut down during process exit
return records
def extract_memories(self, content: str) -> list[str]:
"""Extract discrete memories from a raw content blob using the LLM.
This is a pure helper -- it does NOT store anything.
Call remember() on each returned string to persist them.
Args:
content: Raw text (e.g. task + result dump).
Returns:
List of short, self-contained memory statements.
"""
return extract_memories_from_content(content, self._llm)
def recall(
self,
query: str,
scope: str | None = None,
categories: list[str] | None = None,
limit: int = 10,
depth: Literal["shallow", "deep"] = "deep",
source: str | None = None,
include_private: bool = False,
) -> list[MemoryMatch]:
"""Retrieve relevant memories.
``shallow`` embeds the query directly and runs a single vector search.
``deep`` (default) uses the RecallFlow: the LLM distills the query into
targeted sub-queries, selects scopes, searches in parallel, and applies
confidence-based routing for optional deeper exploration.
Args:
query: Natural language query.
scope: Optional scope prefix to search within.
categories: Optional category filter.
limit: Max number of results.
depth: "shallow" for direct vector search, "deep" for intelligent flow.
source: Optional provenance filter. Private records are only visible
when this matches the record's source.
include_private: If True, all private records are visible regardless of source.
Returns:
List of MemoryMatch, ordered by relevance.
"""
# Read barrier: wait for any pending background saves to finish
# so that the search sees all persisted records.
self.drain_writes()
effective_scope = scope
if effective_scope is None and self.root_scope:
effective_scope = self.root_scope
elif effective_scope is not None and self.root_scope:
effective_scope = join_scope_paths(self.root_scope, effective_scope)
_source = "unified_memory"
try:
with operation(
"recall memory",
{
"crewai.memory.depth": depth,
"crewai.memory.source_type": _source,
},
):
crewai_event_bus.emit(
self,
MemoryQueryStartedEvent(
query=query,
limit=limit,
score_threshold=None,
source_type=_source,
),
)
start = time.perf_counter()
if depth == "shallow":
embedding = embed_text(self._embedder, query)
if not embedding:
results: list[MemoryMatch] = []
else:
raw = self._storage.search(
embedding,
scope_prefix=effective_scope,
categories=categories,
limit=limit,
min_score=0.0,
)
if not include_private:
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
)
results.append(
MemoryMatch(
record=r,
score=composite,
match_reasons=reasons,
)
)
results.sort(key=lambda m: m.score, reverse=True)
else:
from crewai.memory.recall_flow import RecallFlow
flow = RecallFlow(
storage=self._storage,
llm=self._llm,
embedder=self._embedder,
config=self._config,
)
flow.kickoff(
inputs={
"query": query,
"scope": effective_scope,
"categories": categories or [],
"limit": limit,
"source": source,
"include_private": include_private,
}
)
results = flow.state.final_results
if results:
try:
touch = getattr(self._storage, "touch_records", None)
if touch is not None:
touch([m.record.id for m in results])
except Exception: # noqa: S110
pass # Non-critical: don't fail recall because of touch
elapsed_ms = (time.perf_counter() - start) * 1000
crewai_event_bus.emit(
self,
MemoryQueryCompletedEvent(
query=query,
results=results,
limit=limit,
score_threshold=None,
query_time_ms=elapsed_ms,
source_type=_source,
),
)
return results
except Exception as e:
crewai_event_bus.emit(
self,
MemoryQueryFailedEvent(
query=query,
limit=limit,
score_threshold=None,
error=str(e),
source_type=_source,
),
)
raise
def forget(
self,
scope: str | None = None,
categories: list[str] | None = None,
older_than: datetime | None = None,
metadata_filter: dict[str, Any] | None = None,
record_ids: list[str] | None = None,
) -> int:
"""Delete memories matching criteria.
Args:
scope: Scope to delete from. If None and root_scope is set, deletes
only within root_scope.
categories: Filter by categories.
older_than: Delete records older than this datetime.
metadata_filter: Filter by metadata fields.
record_ids: Specific record IDs to delete.
Returns:
Number of records deleted.
"""
effective_scope = scope
if effective_scope is None and self.root_scope:
effective_scope = self.root_scope
elif effective_scope is not None and self.root_scope:
effective_scope = join_scope_paths(self.root_scope, effective_scope)
return self._storage.delete(
scope_prefix=effective_scope,
categories=categories,
record_ids=record_ids,
older_than=older_than,
metadata_filter=metadata_filter,
)
def update(
self,
record_id: str,
content: str | None = None,
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
) -> MemoryRecord:
"""Update an existing memory record by ID.
Args:
record_id: ID of the record to update.
content: New content; re-embedded if provided.
scope: New scope path.
categories: New categories.
metadata: New metadata.
importance: New importance score.
Returns:
The updated MemoryRecord.
Raises:
ValueError: If the record is not found.
"""
existing = self._storage.get_record(record_id)
if existing is None:
raise ValueError(f"Record not found: {record_id}")
now = datetime.utcnow()
updates: dict[str, Any] = {"last_accessed": now}
if content is not None:
updates["content"] = content
embedding = embed_text(self._embedder, content)
updates["embedding"] = embedding if embedding else existing.embedding
if scope is not None:
updates["scope"] = scope
if categories is not None:
updates["categories"] = categories
if metadata is not None:
updates["metadata"] = metadata
if importance is not None:
updates["importance"] = importance
updated = existing.model_copy(update=updates)
self._storage.update(updated)
return updated
def scope(self, path: str) -> Any:
"""Return a scoped view of this memory."""
from crewai.memory.memory_scope import MemoryScope
return MemoryScope(memory=self, root_path=path)
def slice(
self,
scopes: list[str],
categories: list[str] | None = None,
read_only: bool = True,
) -> Any:
"""Return a multi-scope view (slice) of this memory."""
from crewai.memory.memory_scope import MemorySlice
return MemorySlice(
memory=self,
scopes=scopes,
categories=categories,
read_only=read_only,
)
def list_scopes(self, path: str | None = None) -> list[str]:
"""List immediate child scopes under path.
Args:
path: Scope path to list children of. If None and root_scope is set,
defaults to root_scope. Otherwise defaults to '/'.
"""
effective_path = path
if effective_path is None and self.root_scope:
effective_path = self.root_scope
elif effective_path is not None and self.root_scope:
effective_path = join_scope_paths(self.root_scope, effective_path)
elif effective_path is None:
effective_path = "/"
return self._storage.list_scopes(effective_path)
def list_records(
self, scope: str | None = None, limit: int = 200, offset: int = 0
) -> list[MemoryRecord]:
"""List records in a scope, newest first.
Args:
scope: Optional scope path prefix to filter by. If None and root_scope
is set, defaults to root_scope.
limit: Maximum number of records to return.
offset: Number of records to skip (for pagination).
"""
effective_scope = scope
if effective_scope is None and self.root_scope:
effective_scope = self.root_scope
elif effective_scope is not None and self.root_scope:
effective_scope = join_scope_paths(self.root_scope, effective_scope)
return self._storage.list_records(
scope_prefix=effective_scope, limit=limit, offset=offset
)
def info(self, path: str | None = None) -> ScopeInfo:
"""Return scope info for path.
Args:
path: Scope path to get info for. If None and root_scope is set,
defaults to root_scope. Otherwise defaults to '/'.
"""
effective_path = path
if effective_path is None and self.root_scope:
effective_path = self.root_scope
elif effective_path is not None and self.root_scope:
effective_path = join_scope_paths(self.root_scope, effective_path)
elif effective_path is None:
effective_path = "/"
return self._storage.get_scope_info(effective_path)
def tree(self, path: str | None = None, max_depth: int = 3) -> str:
"""Return a formatted tree of scopes (string).
Args:
path: Root path for the tree. If None and root_scope is set,
defaults to root_scope. Otherwise defaults to '/'.
max_depth: Maximum depth to traverse.
"""
effective_path = path
if effective_path is None and self.root_scope:
effective_path = self.root_scope
elif effective_path is not None and self.root_scope:
effective_path = join_scope_paths(self.root_scope, effective_path)
elif effective_path is None:
effective_path = "/"
lines: list[str] = []
def _walk(p: str, depth: int, prefix: str) -> None:
if depth > max_depth:
return
info = self._storage.get_scope_info(p)
lines.append(f"{prefix}{p or '/'} ({info.record_count} records)")
for child in info.child_scopes[:20]:
_walk(child, depth + 1, prefix + " ")
_walk(effective_path.rstrip("/") or "/", 0, "")
return "\n".join(lines) if lines else f"{effective_path or '/'} (0 records)"
def list_categories(self, path: str | None = None) -> dict[str, int]:
"""List categories and counts.
Args:
path: Scope path to filter categories by. If None and root_scope is set,
defaults to root_scope.
"""
effective_path = path
if effective_path is None and self.root_scope:
effective_path = self.root_scope
elif effective_path is not None and self.root_scope:
effective_path = join_scope_paths(self.root_scope, effective_path)
return self._storage.list_categories(scope_prefix=effective_path)
def reset(self, scope: str | None = None) -> None:
"""Reset (delete all) memories in scope.
Args:
scope: Scope to reset. If None and root_scope is set, resets only
within root_scope. If None and no root_scope, resets all.
"""
with self._reset_lock:
self.drain_writes()
effective_scope = scope
if effective_scope is None and self.root_scope:
effective_scope = self.root_scope
elif effective_scope is not None and self.root_scope:
effective_scope = join_scope_paths(self.root_scope, effective_scope)
self._storage.reset(scope_prefix=effective_scope)
def reset_all(self) -> None:
"""Reset the entire backing memory store, ignoring ``root_scope``."""
with self._reset_lock:
self.drain_writes()
self._storage.reset(scope_prefix=None)
async def aextract_memories(self, content: str) -> list[str]:
"""Async variant of extract_memories."""
return self.extract_memories(content)
async def aremember(
self,
content: str,
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
) -> MemoryRecord | None:
"""Async remember: delegates to sync for now."""
return self.remember(
content,
scope=scope,
categories=categories,
metadata=metadata,
importance=importance,
source=source,
private=private,
)
async def aremember_many(
self,
contents: list[str],
scope: str | None = None,
categories: list[str] | None = None,
metadata: dict[str, Any] | None = None,
importance: float | None = None,
source: str | None = None,
private: bool = False,
agent_role: str | None = None,
) -> list[MemoryRecord]:
"""Async remember_many: delegates to sync for now."""
return self.remember_many(
contents,
scope=scope,
categories=categories,
metadata=metadata,
importance=importance,
source=source,
private=private,
agent_role=agent_role,
)
async def arecall(
self,
query: str,
scope: str | None = None,
categories: list[str] | None = None,
limit: int = 10,
depth: Literal["shallow", "deep"] = "deep",
source: str | None = None,
include_private: bool = False,
) -> list[MemoryMatch]:
"""Async recall: delegates to sync for now."""
return self.recall(
query,
scope=scope,
categories=categories,
limit=limit,
depth=depth,
source=source,
include_private=include_private,
)