mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-06 07:29:24 +00:00
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.
1118 lines
42 KiB
Python
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,
|
|
)
|