crewai-memory-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crewai_memory_core-0.1.0/.gitignore +9 -0
- crewai_memory_core-0.1.0/PKG-INFO +54 -0
- crewai_memory_core-0.1.0/README.md +37 -0
- crewai_memory_core-0.1.0/pyproject.toml +29 -0
- crewai_memory_core-0.1.0/src/crewai_memory_core/__init__.py +290 -0
- crewai_memory_core-0.1.0/src/crewai_memory_core/contract.py +244 -0
- crewai_memory_core-0.1.0/src/crewai_memory_core/testing.py +63 -0
- crewai_memory_core-0.1.0/tests/test_core_backend.py +17 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: crewai-memory-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared core for CrewAI unified-memory StorageBackend implementations — build a Memory backend with five small primitives
|
|
5
|
+
Project-URL: Homepage, https://github.com/skamalj/crewai-memory
|
|
6
|
+
Project-URL: Repository, https://github.com/skamalj/crewai-memory.git
|
|
7
|
+
Project-URL: Documentation, https://skamalj.github.io/agentstate-reducer/
|
|
8
|
+
Author-email: Kamal <skamalj@gmail.com>
|
|
9
|
+
Keywords: agent-memory,crewai,long-term-memory,memory,storage-backend,vector-search
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: crewai>=1.10.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# crewai-memory-core
|
|
19
|
+
|
|
20
|
+
Shared core for building [CrewAI](https://docs.crewai.com/en/concepts/memory) unified-memory **`StorageBackend`** implementations. CrewAI 1.10+ has one `Memory` engine (LLM analysis, consolidation, scope inference, composite scoring) over a pluggable storage protocol; this core implements the *whole* protocol — `save` / `search` / `delete` / `update` / `get_record` / `list_records` / `get_scope_info` / `list_scopes` / `list_categories` / `count` / `reset` / `touch_records` and the async variants — so a backend only supplies five primitives:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from crewai_memory_core import MemoryBackend
|
|
24
|
+
|
|
25
|
+
class MyBackend(MemoryBackend):
|
|
26
|
+
def _put(self, rows): ... # upsert rows by id
|
|
27
|
+
def _get(self, record_id): ... # -> row | None
|
|
28
|
+
def _delete_ids(self, ids): ... # -> int deleted
|
|
29
|
+
def _scan(self, scope_prefix): ... # every row in the scope subtree
|
|
30
|
+
# optional — native ANN; default ranks _scan rows by cosine in Python
|
|
31
|
+
def _vector_search(self, vector, scope_prefix, limit): ... # -> [(row, score), ...]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A `row` is a dict of the `MemoryRecord` fields plus `embedding`. Scope paths are hierarchical (`/company/team/user`); a prefix matches the scope itself and everything under it, never a sibling that merely shares characters (`/a` does not match `/ab`).
|
|
35
|
+
|
|
36
|
+
Use a backend with CrewAI:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from crewai import Crew
|
|
40
|
+
from crewai.memory import Memory
|
|
41
|
+
|
|
42
|
+
memory = Memory(storage=MyBackend(...))
|
|
43
|
+
crew = Crew(agents=[...], tasks=[...], memory=memory) # or set_memory_storage_factory(...) once at startup
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`crewai_memory_core.testing` ships `FakeEmbedder` (deterministic, no network — pass as `Memory(embedder=...)`) and `InMemoryBackend`; `crewai_memory_core.contract` is an importable test suite every provider runs, including an end-to-end pass through CrewAI's real `Memory` engine with zero LLM calls.
|
|
47
|
+
|
|
48
|
+
Concrete backends: [`crewai-memory-dynamodb`](https://pypi.org/project/crewai-memory-dynamodb/), [`crewai-memory-postgres`](https://pypi.org/project/crewai-memory-postgres/), [`crewai-memory-cosmosdb`](https://pypi.org/project/crewai-memory-cosmosdb/), [`crewai-memory-firestore`](https://pypi.org/project/crewai-memory-firestore/).
|
|
49
|
+
|
|
50
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/>
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# crewai-memory-core
|
|
2
|
+
|
|
3
|
+
Shared core for building [CrewAI](https://docs.crewai.com/en/concepts/memory) unified-memory **`StorageBackend`** implementations. CrewAI 1.10+ has one `Memory` engine (LLM analysis, consolidation, scope inference, composite scoring) over a pluggable storage protocol; this core implements the *whole* protocol — `save` / `search` / `delete` / `update` / `get_record` / `list_records` / `get_scope_info` / `list_scopes` / `list_categories` / `count` / `reset` / `touch_records` and the async variants — so a backend only supplies five primitives:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from crewai_memory_core import MemoryBackend
|
|
7
|
+
|
|
8
|
+
class MyBackend(MemoryBackend):
|
|
9
|
+
def _put(self, rows): ... # upsert rows by id
|
|
10
|
+
def _get(self, record_id): ... # -> row | None
|
|
11
|
+
def _delete_ids(self, ids): ... # -> int deleted
|
|
12
|
+
def _scan(self, scope_prefix): ... # every row in the scope subtree
|
|
13
|
+
# optional — native ANN; default ranks _scan rows by cosine in Python
|
|
14
|
+
def _vector_search(self, vector, scope_prefix, limit): ... # -> [(row, score), ...]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
A `row` is a dict of the `MemoryRecord` fields plus `embedding`. Scope paths are hierarchical (`/company/team/user`); a prefix matches the scope itself and everything under it, never a sibling that merely shares characters (`/a` does not match `/ab`).
|
|
18
|
+
|
|
19
|
+
Use a backend with CrewAI:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from crewai import Crew
|
|
23
|
+
from crewai.memory import Memory
|
|
24
|
+
|
|
25
|
+
memory = Memory(storage=MyBackend(...))
|
|
26
|
+
crew = Crew(agents=[...], tasks=[...], memory=memory) # or set_memory_storage_factory(...) once at startup
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`crewai_memory_core.testing` ships `FakeEmbedder` (deterministic, no network — pass as `Memory(embedder=...)`) and `InMemoryBackend`; `crewai_memory_core.contract` is an importable test suite every provider runs, including an end-to-end pass through CrewAI's real `Memory` engine with zero LLM calls.
|
|
30
|
+
|
|
31
|
+
Concrete backends: [`crewai-memory-dynamodb`](https://pypi.org/project/crewai-memory-dynamodb/), [`crewai-memory-postgres`](https://pypi.org/project/crewai-memory-postgres/), [`crewai-memory-cosmosdb`](https://pypi.org/project/crewai-memory-cosmosdb/), [`crewai-memory-firestore`](https://pypi.org/project/crewai-memory-firestore/).
|
|
32
|
+
|
|
33
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/>
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crewai-memory-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared core for CrewAI unified-memory StorageBackend implementations — build a Memory backend with five small primitives"
|
|
9
|
+
authors = [{name = "Kamal", email = "skamalj@gmail.com"}]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"crewai>=1.10.0",
|
|
14
|
+
]
|
|
15
|
+
keywords = ["crewai", "memory", "storage-backend", "long-term-memory", "agent-memory", "vector-search"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/skamalj/crewai-memory"
|
|
25
|
+
Repository = "https://github.com/skamalj/crewai-memory.git"
|
|
26
|
+
Documentation = "https://skamalj.github.io/agentstate-reducer/"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/crewai_memory_core"]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Shared core for CrewAI unified-memory ``StorageBackend`` implementations.
|
|
2
|
+
|
|
3
|
+
CrewAI 1.10+ has one ``Memory`` engine (LLM analysis, consolidation, composite
|
|
4
|
+
scoring) and a pluggable ``crewai.memory.storage.backend.StorageBackend``
|
|
5
|
+
protocol underneath it. ``MemoryBackend`` implements the *whole* protocol —
|
|
6
|
+
save / search / delete / update / get_record / list_records / get_scope_info /
|
|
7
|
+
list_scopes / list_categories / count / reset / touch_records and the async
|
|
8
|
+
variants — on top of five primitives a datastore supplies:
|
|
9
|
+
|
|
10
|
+
_put(rows) -> None upsert rows by id
|
|
11
|
+
_get(record_id) -> row | None
|
|
12
|
+
_delete_ids(ids) -> int
|
|
13
|
+
_scan(scope_prefix) -> list[row] every row in the scope subtree
|
|
14
|
+
_vector_search(vector, scope_prefix, limit) -> list[(row, score)] (optional)
|
|
15
|
+
|
|
16
|
+
A ``row`` is a plain dict with the ``MemoryRecord`` fields (``id``, ``content``,
|
|
17
|
+
``scope``, ``categories``, ``metadata``, ``importance``, ``created_at``,
|
|
18
|
+
``last_accessed``, ``source``, ``private``) plus ``embedding``. ``score`` is a
|
|
19
|
+
cosine similarity, higher is better. The default ``_vector_search`` ranks
|
|
20
|
+
``_scan`` rows in Python, so a backend works before it has a native path.
|
|
21
|
+
|
|
22
|
+
Scope semantics: ``scope_prefix="/a"`` matches ``/a`` and everything under
|
|
23
|
+
``/a/...`` but not ``/ab``; ``"/"`` or ``None`` matches everything.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import math
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
32
|
+
|
|
33
|
+
from crewai.memory.types import MemoryRecord, ScopeInfo
|
|
34
|
+
|
|
35
|
+
__all__ = ["MemoryBackend", "record_to_row", "row_to_record", "in_scope", "norm_scope", "cosine_similarity"]
|
|
36
|
+
|
|
37
|
+
ROW_FIELDS = (
|
|
38
|
+
"id", "content", "scope", "categories", "metadata", "importance",
|
|
39
|
+
"created_at", "last_accessed", "source", "private", "embedding",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def norm_scope(scope: Optional[str]) -> str:
|
|
44
|
+
"""Normalise a scope path: leading slash, no trailing slash, ``/`` for root."""
|
|
45
|
+
s = (scope or "/").strip()
|
|
46
|
+
if not s.startswith("/"):
|
|
47
|
+
s = "/" + s
|
|
48
|
+
s = s.rstrip("/")
|
|
49
|
+
return s or "/"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def in_scope(scope: str, prefix: Optional[str]) -> bool:
|
|
53
|
+
"""True if ``scope`` is ``prefix`` itself or lies under it."""
|
|
54
|
+
p = norm_scope(prefix)
|
|
55
|
+
if p == "/":
|
|
56
|
+
return True
|
|
57
|
+
s = norm_scope(scope)
|
|
58
|
+
return s == p or s.startswith(p + "/")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
|
62
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
63
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
64
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
65
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_dt(val: Any) -> datetime:
|
|
69
|
+
if isinstance(val, datetime):
|
|
70
|
+
return val
|
|
71
|
+
if val is None:
|
|
72
|
+
return datetime.utcnow()
|
|
73
|
+
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def record_to_row(record: MemoryRecord) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"id": record.id,
|
|
79
|
+
"content": record.content,
|
|
80
|
+
"scope": norm_scope(record.scope),
|
|
81
|
+
"categories": list(record.categories or []),
|
|
82
|
+
"metadata": dict(record.metadata or {}),
|
|
83
|
+
"importance": float(record.importance),
|
|
84
|
+
"created_at": record.created_at.isoformat(),
|
|
85
|
+
"last_accessed": record.last_accessed.isoformat(),
|
|
86
|
+
"source": record.source,
|
|
87
|
+
"private": bool(record.private),
|
|
88
|
+
"embedding": list(record.embedding) if record.embedding else None,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def row_to_record(row: dict) -> MemoryRecord:
|
|
93
|
+
return MemoryRecord(
|
|
94
|
+
id=str(row["id"]),
|
|
95
|
+
content=str(row.get("content", "")),
|
|
96
|
+
scope=norm_scope(row.get("scope")),
|
|
97
|
+
categories=list(row.get("categories") or []),
|
|
98
|
+
metadata=dict(row.get("metadata") or {}),
|
|
99
|
+
importance=float(row.get("importance", 0.5)),
|
|
100
|
+
created_at=_parse_dt(row.get("created_at")),
|
|
101
|
+
last_accessed=_parse_dt(row.get("last_accessed")),
|
|
102
|
+
embedding=list(row["embedding"]) if row.get("embedding") else None,
|
|
103
|
+
source=row.get("source") or None,
|
|
104
|
+
private=bool(row.get("private", False)),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _matches(row: dict, categories: Optional[List[str]], metadata_filter: Optional[Dict[str, Any]],
|
|
109
|
+
older_than: Optional[datetime] = None) -> bool:
|
|
110
|
+
if categories and not any(c in (row.get("categories") or []) for c in categories):
|
|
111
|
+
return False
|
|
112
|
+
if metadata_filter:
|
|
113
|
+
md = row.get("metadata") or {}
|
|
114
|
+
if not all(md.get(k) == v for k, v in metadata_filter.items()):
|
|
115
|
+
return False
|
|
116
|
+
if older_than is not None and _parse_dt(row.get("created_at")) >= older_than:
|
|
117
|
+
return False
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MemoryBackend:
|
|
122
|
+
"""A CrewAI ``StorageBackend`` implemented over five datastore primitives."""
|
|
123
|
+
|
|
124
|
+
supports_native_vector_search: bool = False
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ primitives
|
|
127
|
+
def _put(self, rows: List[dict]) -> None:
|
|
128
|
+
raise NotImplementedError
|
|
129
|
+
|
|
130
|
+
def _get(self, record_id: str) -> Optional[dict]:
|
|
131
|
+
raise NotImplementedError
|
|
132
|
+
|
|
133
|
+
def _delete_ids(self, ids: List[str]) -> int:
|
|
134
|
+
raise NotImplementedError
|
|
135
|
+
|
|
136
|
+
def _scan(self, scope_prefix: Optional[str]) -> List[dict]:
|
|
137
|
+
"""Every row whose scope is ``scope_prefix`` or under it (all rows for "/" / None)."""
|
|
138
|
+
raise NotImplementedError
|
|
139
|
+
|
|
140
|
+
def _vector_search(
|
|
141
|
+
self, vector: List[float], scope_prefix: Optional[str], limit: int
|
|
142
|
+
) -> List[Tuple[dict, float]]:
|
|
143
|
+
"""Up to ``limit`` ``(row, cosine_similarity)`` pairs in the scope subtree, best first."""
|
|
144
|
+
scored = [
|
|
145
|
+
(row, cosine_similarity(vector, row["embedding"]))
|
|
146
|
+
for row in self._scan(scope_prefix)
|
|
147
|
+
if row.get("embedding")
|
|
148
|
+
]
|
|
149
|
+
scored.sort(key=lambda rs: -rs[1])
|
|
150
|
+
return scored[:limit]
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------ StorageBackend
|
|
153
|
+
def save(self, records: List[MemoryRecord]) -> None:
|
|
154
|
+
if records:
|
|
155
|
+
self._put([record_to_row(r) for r in records])
|
|
156
|
+
|
|
157
|
+
def update(self, record: MemoryRecord) -> None:
|
|
158
|
+
self._put([record_to_row(record)])
|
|
159
|
+
|
|
160
|
+
def get_record(self, record_id: str) -> Optional[MemoryRecord]:
|
|
161
|
+
row = self._get(record_id)
|
|
162
|
+
return row_to_record(row) if row else None
|
|
163
|
+
|
|
164
|
+
def search(
|
|
165
|
+
self,
|
|
166
|
+
query_embedding: List[float],
|
|
167
|
+
scope_prefix: Optional[str] = None,
|
|
168
|
+
categories: Optional[List[str]] = None,
|
|
169
|
+
metadata_filter: Optional[Dict[str, Any]] = None,
|
|
170
|
+
limit: int = 10,
|
|
171
|
+
min_score: float = 0.0,
|
|
172
|
+
) -> List[Tuple[MemoryRecord, float]]:
|
|
173
|
+
if not query_embedding:
|
|
174
|
+
return []
|
|
175
|
+
oversample = limit * 3 if (categories or metadata_filter) else limit
|
|
176
|
+
hits = self._vector_search(list(query_embedding), scope_prefix, oversample)
|
|
177
|
+
out: List[Tuple[MemoryRecord, float]] = []
|
|
178
|
+
for row, score in hits:
|
|
179
|
+
if not in_scope(row["scope"], scope_prefix):
|
|
180
|
+
continue
|
|
181
|
+
if not _matches(row, categories, metadata_filter):
|
|
182
|
+
continue
|
|
183
|
+
if score >= min_score:
|
|
184
|
+
out.append((row_to_record(row), float(score)))
|
|
185
|
+
if len(out) >= limit:
|
|
186
|
+
break
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
def delete(
|
|
190
|
+
self,
|
|
191
|
+
scope_prefix: Optional[str] = None,
|
|
192
|
+
categories: Optional[List[str]] = None,
|
|
193
|
+
record_ids: Optional[List[str]] = None,
|
|
194
|
+
older_than: Optional[datetime] = None,
|
|
195
|
+
metadata_filter: Optional[Dict[str, Any]] = None,
|
|
196
|
+
) -> int:
|
|
197
|
+
if record_ids and not (categories or metadata_filter or older_than):
|
|
198
|
+
return self._delete_ids(list(record_ids))
|
|
199
|
+
rows = self._scan(scope_prefix)
|
|
200
|
+
if record_ids:
|
|
201
|
+
wanted = set(record_ids)
|
|
202
|
+
rows = [r for r in rows if r["id"] in wanted]
|
|
203
|
+
ids = [r["id"] for r in rows if _matches(r, categories, metadata_filter, older_than)]
|
|
204
|
+
return self._delete_ids(ids) if ids else 0
|
|
205
|
+
|
|
206
|
+
def list_records(self, scope_prefix: Optional[str] = None, limit: int = 200, offset: int = 0) -> List[MemoryRecord]:
|
|
207
|
+
rows = sorted(self._scan(scope_prefix), key=lambda r: _parse_dt(r.get("created_at")), reverse=True)
|
|
208
|
+
return [row_to_record(r) for r in rows[offset : offset + limit]]
|
|
209
|
+
|
|
210
|
+
def get_scope_info(self, scope: str) -> ScopeInfo:
|
|
211
|
+
scope = norm_scope(scope)
|
|
212
|
+
rows = self._scan(scope)
|
|
213
|
+
cats: set = set()
|
|
214
|
+
oldest = newest = None
|
|
215
|
+
children: set = set()
|
|
216
|
+
child_prefix = "/" if scope == "/" else scope + "/"
|
|
217
|
+
for r in rows:
|
|
218
|
+
sc = norm_scope(r["scope"])
|
|
219
|
+
if sc.startswith(child_prefix):
|
|
220
|
+
first = sc[len(child_prefix):].split("/", 1)[0]
|
|
221
|
+
if first:
|
|
222
|
+
children.add(child_prefix + first)
|
|
223
|
+
cats.update(r.get("categories") or [])
|
|
224
|
+
dt = _parse_dt(r.get("created_at"))
|
|
225
|
+
oldest = dt if oldest is None or dt < oldest else oldest
|
|
226
|
+
newest = dt if newest is None or dt > newest else newest
|
|
227
|
+
return ScopeInfo(
|
|
228
|
+
path=scope, record_count=len(rows), categories=sorted(cats),
|
|
229
|
+
oldest_record=oldest, newest_record=newest, child_scopes=sorted(children),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def list_scopes(self, parent: str = "/") -> List[str]:
|
|
233
|
+
parent = norm_scope(parent)
|
|
234
|
+
child_prefix = "/" if parent == "/" else parent + "/"
|
|
235
|
+
children: set = set()
|
|
236
|
+
for r in self._scan(parent):
|
|
237
|
+
sc = norm_scope(r["scope"])
|
|
238
|
+
if sc.startswith(child_prefix) and sc != parent:
|
|
239
|
+
first = sc[len(child_prefix):].split("/", 1)[0]
|
|
240
|
+
if first:
|
|
241
|
+
children.add(child_prefix + first)
|
|
242
|
+
return sorted(children)
|
|
243
|
+
|
|
244
|
+
def list_categories(self, scope_prefix: Optional[str] = None) -> Dict[str, int]:
|
|
245
|
+
counts: Dict[str, int] = {}
|
|
246
|
+
for r in self._scan(scope_prefix):
|
|
247
|
+
for c in r.get("categories") or []:
|
|
248
|
+
counts[c] = counts.get(c, 0) + 1
|
|
249
|
+
return counts
|
|
250
|
+
|
|
251
|
+
def count(self, scope_prefix: Optional[str] = None) -> int:
|
|
252
|
+
return len(self._scan(scope_prefix))
|
|
253
|
+
|
|
254
|
+
def reset(self, scope_prefix: Optional[str] = None) -> None:
|
|
255
|
+
ids = [r["id"] for r in self._scan(scope_prefix)]
|
|
256
|
+
if ids:
|
|
257
|
+
self._delete_ids(ids)
|
|
258
|
+
|
|
259
|
+
def touch_records(self, record_ids: List[str]) -> None:
|
|
260
|
+
"""Bump ``last_accessed`` (called by ``Memory.recall``; best-effort)."""
|
|
261
|
+
now = datetime.utcnow().isoformat()
|
|
262
|
+
rows = []
|
|
263
|
+
for rid in record_ids:
|
|
264
|
+
row = self._get(rid)
|
|
265
|
+
if row:
|
|
266
|
+
row["last_accessed"] = now
|
|
267
|
+
rows.append(row)
|
|
268
|
+
if rows:
|
|
269
|
+
self._put(rows)
|
|
270
|
+
|
|
271
|
+
def close(self) -> None: # pragma: no cover - default no-op
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------------ async surface
|
|
275
|
+
async def asave(self, records: List[MemoryRecord]) -> None:
|
|
276
|
+
await asyncio.to_thread(self.save, records)
|
|
277
|
+
|
|
278
|
+
async def asearch(self, query_embedding: List[float], scope_prefix: Optional[str] = None,
|
|
279
|
+
categories: Optional[List[str]] = None, metadata_filter: Optional[Dict[str, Any]] = None,
|
|
280
|
+
limit: int = 10, min_score: float = 0.0) -> List[Tuple[MemoryRecord, float]]:
|
|
281
|
+
return await asyncio.to_thread(
|
|
282
|
+
self.search, query_embedding, scope_prefix, categories, metadata_filter, limit, min_score
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def adelete(self, scope_prefix: Optional[str] = None, categories: Optional[List[str]] = None,
|
|
286
|
+
record_ids: Optional[List[str]] = None, older_than: Optional[datetime] = None,
|
|
287
|
+
metadata_filter: Optional[Dict[str, Any]] = None) -> int:
|
|
288
|
+
return await asyncio.to_thread(
|
|
289
|
+
self.delete, scope_prefix, categories, record_ids, older_than, metadata_filter
|
|
290
|
+
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Reusable ``StorageBackend`` contract tests.
|
|
2
|
+
|
|
3
|
+
A provider's test module supplies a ``backend`` fixture (fresh, empty store)
|
|
4
|
+
and does ``from crewai_memory_core.contract import *``. Every test below then
|
|
5
|
+
runs against that backend, including an end-to-end pass through CrewAI's real
|
|
6
|
+
``Memory`` engine with the deterministic ``FakeEmbedder`` (no LLM: all record
|
|
7
|
+
fields are provided so the encoding flow takes its zero-LLM fast path, and
|
|
8
|
+
recall uses ``depth="shallow"``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from crewai.memory.types import MemoryRecord
|
|
18
|
+
|
|
19
|
+
from .testing import FakeEmbedder
|
|
20
|
+
|
|
21
|
+
DIMS = 64
|
|
22
|
+
EMB = FakeEmbedder(DIMS)
|
|
23
|
+
|
|
24
|
+
__all__ = [n for n in dir() if n.startswith("test_")] # filled after definitions
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rec(content, scope="/", categories=None, importance=0.5, metadata=None, source=None, private=False, created=None):
|
|
28
|
+
return MemoryRecord(
|
|
29
|
+
content=content, scope=scope, categories=categories or [], importance=importance,
|
|
30
|
+
metadata=metadata or {}, source=source, private=private,
|
|
31
|
+
embedding=EMB([content])[0], **({"created_at": created} if created else {}),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _seed(b):
|
|
36
|
+
recs = [
|
|
37
|
+
_rec("the user loves sushi and japanese food", "/crew/support/user/kamal", ["food", "pref"], 0.9, {"lang": "en"}),
|
|
38
|
+
_rec("the user writes python every day", "/crew/support/user/kamal", ["tech"], 0.6, {"lang": "en"}),
|
|
39
|
+
_rec("the user lives by the sea in a big city", "/crew/support/user/kamal", ["place"], 0.4, {"lang": "fr"}),
|
|
40
|
+
_rec("priya loves sushi", "/crew/support/user/priya", ["food"], 0.5),
|
|
41
|
+
_rec("company policy: remote work allowed", "/company", ["policy"], 0.8),
|
|
42
|
+
]
|
|
43
|
+
b.save(recs)
|
|
44
|
+
return recs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _wait(fn, cond, tries=15, delay=1.0):
|
|
48
|
+
for _ in range(tries):
|
|
49
|
+
out = fn()
|
|
50
|
+
if cond(out):
|
|
51
|
+
return out
|
|
52
|
+
time.sleep(delay)
|
|
53
|
+
return fn()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── save / get / update / delete ──────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_save_and_get_roundtrip(backend):
|
|
60
|
+
r = _rec("hello world", "/a/b", ["x"], 0.7, {"k": 1, "s": "v"}, source="u1", private=True)
|
|
61
|
+
backend.save([r])
|
|
62
|
+
got = _wait(lambda: backend.get_record(r.id), lambda g: g is not None)
|
|
63
|
+
assert got.id == r.id and got.content == "hello world" and got.scope == "/a/b"
|
|
64
|
+
assert got.categories == ["x"] and got.metadata == {"k": 1, "s": "v"}
|
|
65
|
+
assert got.importance == pytest.approx(0.7) and got.source == "u1" and got.private is True
|
|
66
|
+
assert abs((got.created_at - r.created_at).total_seconds()) < 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_get_missing_is_none(backend):
|
|
70
|
+
assert backend.get_record("nope") is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_update_replaces_record(backend):
|
|
74
|
+
r = _rec("v1", "/a")
|
|
75
|
+
backend.save([r])
|
|
76
|
+
r2 = r.model_copy(update={"content": "v2", "importance": 0.99, "embedding": EMB(["v2"])[0]})
|
|
77
|
+
backend.update(r2)
|
|
78
|
+
got = _wait(lambda: backend.get_record(r.id), lambda g: g and g.content == "v2")
|
|
79
|
+
assert got.content == "v2" and got.importance == pytest.approx(0.99)
|
|
80
|
+
assert backend.count() == 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_delete_by_ids(backend):
|
|
84
|
+
recs = _seed(backend)
|
|
85
|
+
_wait(lambda: backend.count(), lambda n: n == 5)
|
|
86
|
+
assert backend.delete(record_ids=[recs[0].id, recs[1].id]) == 2
|
|
87
|
+
assert _wait(lambda: backend.count(), lambda n: n == 3) == 3
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_delete_by_scope_categories_metadata_and_age(backend):
|
|
91
|
+
_seed(backend)
|
|
92
|
+
old = _rec("ancient fact", "/crew/support/user/kamal", ["old"], created=datetime.utcnow() - timedelta(days=400))
|
|
93
|
+
backend.save([old])
|
|
94
|
+
_wait(lambda: backend.count(), lambda n: n == 6)
|
|
95
|
+
assert backend.delete(scope_prefix="/crew/support/user/kamal", categories=["tech"]) == 1
|
|
96
|
+
assert backend.delete(scope_prefix="/crew", metadata_filter={"lang": "fr"}) == 1
|
|
97
|
+
assert backend.delete(older_than=datetime.utcnow() - timedelta(days=30)) == 1
|
|
98
|
+
assert _wait(lambda: backend.count(), lambda n: n == 3) == 3
|
|
99
|
+
assert backend.delete(scope_prefix="/crew/support/user/kamal") == 1 # remaining kamal record
|
|
100
|
+
assert backend.count("/company") == 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_reset_scope_and_all(backend):
|
|
104
|
+
_seed(backend)
|
|
105
|
+
_wait(lambda: backend.count(), lambda n: n == 5)
|
|
106
|
+
backend.reset("/crew/support/user")
|
|
107
|
+
assert _wait(lambda: backend.count(), lambda n: n == 1) == 1
|
|
108
|
+
backend.reset()
|
|
109
|
+
assert _wait(lambda: backend.count(), lambda n: n == 0) == 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── scopes / categories / listing ─────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_scope_prefix_is_path_aware(backend):
|
|
116
|
+
backend.save([_rec("a", "/a"), _rec("ab", "/ab"), _rec("a-child", "/a/child")])
|
|
117
|
+
_wait(lambda: backend.count(), lambda n: n == 3)
|
|
118
|
+
assert backend.count("/a") == 2 # /a and /a/child, not /ab
|
|
119
|
+
assert backend.count("/ab") == 1
|
|
120
|
+
assert backend.count("/") == 3 and backend.count(None) == 3
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_list_scopes_immediate_children(backend):
|
|
124
|
+
_seed(backend)
|
|
125
|
+
_wait(lambda: backend.count(), lambda n: n == 5)
|
|
126
|
+
assert backend.list_scopes("/") == ["/company", "/crew"]
|
|
127
|
+
assert backend.list_scopes("/crew/support") == ["/crew/support/user"]
|
|
128
|
+
assert backend.list_scopes("/crew/support/user") == ["/crew/support/user/kamal", "/crew/support/user/priya"]
|
|
129
|
+
assert backend.list_scopes("/nowhere") == []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_get_scope_info(backend):
|
|
133
|
+
_seed(backend)
|
|
134
|
+
_wait(lambda: backend.count(), lambda n: n == 5)
|
|
135
|
+
info = backend.get_scope_info("/crew/support/user")
|
|
136
|
+
assert info.path == "/crew/support/user" and info.record_count == 4
|
|
137
|
+
assert set(info.categories) == {"food", "pref", "tech", "place"}
|
|
138
|
+
assert info.child_scopes == ["/crew/support/user/kamal", "/crew/support/user/priya"]
|
|
139
|
+
assert info.oldest_record is not None and info.newest_record is not None
|
|
140
|
+
assert backend.get_scope_info("/empty").record_count == 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_list_categories_counts(backend):
|
|
144
|
+
_seed(backend)
|
|
145
|
+
_wait(lambda: backend.count(), lambda n: n == 5)
|
|
146
|
+
assert backend.list_categories() == {"food": 2, "pref": 1, "tech": 1, "place": 1, "policy": 1}
|
|
147
|
+
assert backend.list_categories("/crew/support/user/kamal") == {"food": 1, "pref": 1, "tech": 1, "place": 1}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_list_records_newest_first_with_paging(backend):
|
|
151
|
+
recs = [_rec(f"r{i}", "/x", created=datetime.utcnow() - timedelta(minutes=10 - i)) for i in range(5)]
|
|
152
|
+
backend.save(recs)
|
|
153
|
+
_wait(lambda: backend.count(), lambda n: n == 5)
|
|
154
|
+
page1 = backend.list_records("/x", limit=2)
|
|
155
|
+
page2 = backend.list_records("/x", limit=2, offset=2)
|
|
156
|
+
assert [r.content for r in page1] == ["r4", "r3"] and [r.content for r in page2] == ["r2", "r1"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── search ────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_search_ranks_by_similarity_with_scores(backend):
|
|
163
|
+
_seed(backend)
|
|
164
|
+
q = EMB(["sushi and japanese food"])[0]
|
|
165
|
+
hits = _wait(lambda: backend.search(q, scope_prefix="/crew/support/user/kamal", limit=3), lambda h: len(h) == 3)
|
|
166
|
+
assert hits[0][0].content.startswith("the user loves sushi")
|
|
167
|
+
scores = [s for _, s in hits]
|
|
168
|
+
assert scores == sorted(scores, reverse=True) and all(-1.0 <= s <= 1.0001 for s in scores)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_search_scope_prefix_covers_subtree_only(backend):
|
|
172
|
+
_seed(backend)
|
|
173
|
+
q = EMB(["sushi"])[0]
|
|
174
|
+
hits = _wait(lambda: backend.search(q, scope_prefix="/crew/support/user", limit=10), lambda h: len(h) == 4)
|
|
175
|
+
assert {r.scope for r, _ in hits} == {"/crew/support/user/kamal", "/crew/support/user/priya"}
|
|
176
|
+
assert all(r.scope.startswith("/crew") for r, _ in backend.search(q, scope_prefix="/crew", limit=10))
|
|
177
|
+
assert backend.search(q, scope_prefix="/nowhere", limit=10) == []
|
|
178
|
+
assert len(backend.search(q, limit=10)) == 5
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_search_categories_metadata_and_min_score(backend):
|
|
182
|
+
_seed(backend)
|
|
183
|
+
q = EMB(["the user"])[0]
|
|
184
|
+
food = _wait(lambda: backend.search(q, scope_prefix="/crew", categories=["food"], limit=10), lambda h: len(h) == 2)
|
|
185
|
+
assert all("food" in r.categories for r, _ in food)
|
|
186
|
+
en = backend.search(q, scope_prefix="/crew", metadata_filter={"lang": "en"}, limit=10)
|
|
187
|
+
assert len(en) == 2 and all(r.metadata.get("lang") == "en" for r, _ in en)
|
|
188
|
+
assert backend.search(q, scope_prefix="/crew", min_score=0.999, limit=10) == []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_search_limit(backend):
|
|
192
|
+
_seed(backend)
|
|
193
|
+
q = EMB(["user"])[0]
|
|
194
|
+
assert len(_wait(lambda: backend.search(q, limit=2), lambda h: len(h) == 2)) == 2
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_touch_records_bumps_last_accessed(backend):
|
|
198
|
+
r = _rec("touch me", "/t")
|
|
199
|
+
backend.save([r])
|
|
200
|
+
_wait(lambda: backend.get_record(r.id), lambda g: g is not None)
|
|
201
|
+
before = backend.get_record(r.id).last_accessed
|
|
202
|
+
time.sleep(0.01)
|
|
203
|
+
backend.touch_records([r.id])
|
|
204
|
+
after = _wait(lambda: backend.get_record(r.id).last_accessed, lambda a: a > before)
|
|
205
|
+
assert after > before
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def test_async_surface(backend):
|
|
209
|
+
r = _rec("async fact", "/async", ["a"])
|
|
210
|
+
await backend.asave([r])
|
|
211
|
+
_wait(lambda: backend.count("/async"), lambda n: n == 1)
|
|
212
|
+
hits = await backend.asearch(EMB(["async fact"])[0], scope_prefix="/async", limit=5)
|
|
213
|
+
assert hits and hits[0][0].id == r.id
|
|
214
|
+
assert await backend.adelete(record_ids=[r.id]) == 1
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── end to end through CrewAI's Memory engine (no LLM) ────────────────────────
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_crewai_memory_engine_roundtrip(backend):
|
|
221
|
+
from crewai.memory import Memory
|
|
222
|
+
|
|
223
|
+
mem = Memory(storage=backend, embedder=EMB, consolidation_threshold=1.0) # 1.0 disables LLM consolidation
|
|
224
|
+
try:
|
|
225
|
+
r1 = mem.remember("the user loves sushi", scope="/user/kamal", categories=["food"], importance=0.9)
|
|
226
|
+
r2 = mem.remember("the user writes python", scope="/user/kamal", categories=["tech"], importance=0.6)
|
|
227
|
+
mem.remember("weather is sunny", scope="/misc", categories=["weather"], importance=0.1)
|
|
228
|
+
assert r1 is not None and r2 is not None
|
|
229
|
+
_wait(lambda: backend.count(), lambda n: n == 3)
|
|
230
|
+
|
|
231
|
+
matches = _wait(lambda: mem.recall("what food does the user like?", scope="/user/kamal", depth="shallow", limit=2),
|
|
232
|
+
lambda m: len(m) == 2)
|
|
233
|
+
assert matches[0].record.content == "the user loves sushi"
|
|
234
|
+
assert 0.0 <= matches[0].score <= 1.0 and "semantic" in matches[0].match_reasons
|
|
235
|
+
|
|
236
|
+
assert mem.list_scopes("/") == ["/misc", "/user"]
|
|
237
|
+
assert mem.info("/user/kamal").record_count == 2
|
|
238
|
+
assert mem.forget(scope="/misc") == 1
|
|
239
|
+
assert _wait(lambda: backend.count(), lambda n: n == 2) == 2
|
|
240
|
+
finally:
|
|
241
|
+
mem.close()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
__all__ = [n for n in list(globals()) if n.startswith("test_")]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Test helpers shared by the provider suites (and handy for users' tests).
|
|
2
|
+
|
|
3
|
+
``FakeEmbedder`` is a deterministic, dependency-free embedder with CrewAI's
|
|
4
|
+
embedder call signature (``list[str] -> list[list[float]]``): a hashed
|
|
5
|
+
bag-of-words projected into ``dims`` dimensions and L2-normalised. Texts that
|
|
6
|
+
share words are close; no network call is made. Pass it as
|
|
7
|
+
``Memory(embedder=FakeEmbedder(64))``.
|
|
8
|
+
|
|
9
|
+
``InMemoryBackend`` is the smallest ``MemoryBackend`` — a dict — used to test
|
|
10
|
+
the core machinery without any datastore.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from . import MemoryBackend, in_scope
|
|
21
|
+
|
|
22
|
+
__all__ = ["FakeEmbedder", "InMemoryBackend"]
|
|
23
|
+
|
|
24
|
+
_WORD = re.compile(r"[a-z0-9]+")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeEmbedder:
|
|
28
|
+
def __init__(self, dims: int = 64) -> None:
|
|
29
|
+
self.dims = dims
|
|
30
|
+
|
|
31
|
+
def _embed(self, text: str) -> List[float]:
|
|
32
|
+
vec = [0.0] * self.dims
|
|
33
|
+
for word in _WORD.findall(text.lower()):
|
|
34
|
+
h = int(hashlib.md5(word.encode()).hexdigest(), 16)
|
|
35
|
+
vec[h % self.dims] += 1.0
|
|
36
|
+
norm = math.sqrt(sum(v * v for v in vec))
|
|
37
|
+
return [v / norm for v in vec] if norm else [1.0 / math.sqrt(self.dims)] * self.dims
|
|
38
|
+
|
|
39
|
+
def __call__(self, texts: List[str]) -> List[List[float]]:
|
|
40
|
+
return [self._embed(t) for t in texts]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InMemoryBackend(MemoryBackend):
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._rows: Dict[str, dict] = {}
|
|
46
|
+
|
|
47
|
+
def _put(self, rows: List[dict]) -> None:
|
|
48
|
+
for r in rows:
|
|
49
|
+
self._rows[r["id"]] = dict(r)
|
|
50
|
+
|
|
51
|
+
def _get(self, record_id: str) -> Optional[dict]:
|
|
52
|
+
r = self._rows.get(record_id)
|
|
53
|
+
return dict(r) if r else None
|
|
54
|
+
|
|
55
|
+
def _delete_ids(self, ids: List[str]) -> int:
|
|
56
|
+
n = 0
|
|
57
|
+
for i in ids:
|
|
58
|
+
if self._rows.pop(i, None) is not None:
|
|
59
|
+
n += 1
|
|
60
|
+
return n
|
|
61
|
+
|
|
62
|
+
def _scan(self, scope_prefix: Optional[str]) -> List[dict]:
|
|
63
|
+
return [dict(r) for r in self._rows.values() if in_scope(r["scope"], scope_prefix)]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Core contract on the dict-backed InMemoryBackend (offline)."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from crewai_memory_core.contract import * # noqa: F401,F403
|
|
5
|
+
from crewai_memory_core import in_scope, norm_scope
|
|
6
|
+
from crewai_memory_core.testing import InMemoryBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture()
|
|
10
|
+
def backend():
|
|
11
|
+
return InMemoryBackend()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_norm_and_in_scope():
|
|
15
|
+
assert norm_scope(None) == "/" and norm_scope("a/b/") == "/a/b" and norm_scope("/") == "/"
|
|
16
|
+
assert in_scope("/a/b", "/a") and in_scope("/a", "/a") and not in_scope("/ab", "/a")
|
|
17
|
+
assert in_scope("/anything", "/") and in_scope("/x", None)
|