crewai-memory-firestore 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_firestore-0.1.0/.gitignore +9 -0
- crewai_memory_firestore-0.1.0/PKG-INFO +55 -0
- crewai_memory_firestore-0.1.0/README.md +37 -0
- crewai_memory_firestore-0.1.0/pyproject.toml +36 -0
- crewai_memory_firestore-0.1.0/src/crewai_memory_firestore/__init__.py +166 -0
- crewai_memory_firestore-0.1.0/tests/test_firestore_backend.py +68 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: crewai-memory-firestore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Google Firestore StorageBackend for CrewAI unified Memory — hierarchical scopes, categories, metadata filters and native vector search (find_nearest)
|
|
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,firestore,gcp,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-memory-core>=0.1.0
|
|
16
|
+
Requires-Dist: google-cloud-firestore>=2.16
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# crewai-memory-firestore
|
|
20
|
+
|
|
21
|
+
A **Google Firestore** `StorageBackend` for [CrewAI](https://docs.crewai.com/en/concepts/memory)'s unified `Memory` — hierarchical scopes, categories, metadata filters, importance/recency, and **native vector search** via `find_nearest`.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install crewai-memory-firestore
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from crewai import Crew
|
|
29
|
+
from crewai.memory import Memory
|
|
30
|
+
from crewai_memory_firestore import FirestoreMemoryBackend
|
|
31
|
+
|
|
32
|
+
backend = FirestoreMemoryBackend(project_id="my-project", collection="crewai_memory", dimensions=3072) # match your embedder
|
|
33
|
+
memory = Memory(storage=backend)
|
|
34
|
+
crew = Crew(agents=[...], tasks=[...], memory=memory)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or once for every `Crew(memory=True)`: `set_memory_storage_factory(lambda spec: FirestoreMemoryBackend(...))`. Authentication uses Application Default Credentials.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
- One collection, one document per record (doc id = record id) with the embedding stored as a Firestore `Vector`.
|
|
42
|
+
- `search` uses native `find_nearest` (cosine) after a scope pre-filter. A scope *subtree* is two queries (the exact scope, and the `scope/…` range) merged by similarity; score is `1 - distance`. `categories` / `metadata_filter` / `min_score` are applied on the candidates.
|
|
43
|
+
- Firestore requires a **composite vector index** `scope ASC + embedding (flat, dims)`. With `create_index=True` (default) the backend creates it through the Admin API if missing; the build is asynchronous and vector queries fail with `FAILED_PRECONDITION` until it is READY (a few minutes). Needs `datastore.indexes.create`/`list`, or create it yourself and pass `create_index=False`:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
gcloud firestore indexes composite create --collection-group=crewai_memory --query-scope=COLLECTION \
|
|
47
|
+
--field-config=order=ASCENDING,field-path=scope \
|
|
48
|
+
--field-config=vector-config='{"dimension":"3072","flat":"{}"}',field-path=embedding
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/> · part of [crewai-memory](https://github.com/skamalj/crewai-memory)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# crewai-memory-firestore
|
|
2
|
+
|
|
3
|
+
A **Google Firestore** `StorageBackend` for [CrewAI](https://docs.crewai.com/en/concepts/memory)'s unified `Memory` — hierarchical scopes, categories, metadata filters, importance/recency, and **native vector search** via `find_nearest`.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install crewai-memory-firestore
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from crewai import Crew
|
|
11
|
+
from crewai.memory import Memory
|
|
12
|
+
from crewai_memory_firestore import FirestoreMemoryBackend
|
|
13
|
+
|
|
14
|
+
backend = FirestoreMemoryBackend(project_id="my-project", collection="crewai_memory", dimensions=3072) # match your embedder
|
|
15
|
+
memory = Memory(storage=backend)
|
|
16
|
+
crew = Crew(agents=[...], tasks=[...], memory=memory)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or once for every `Crew(memory=True)`: `set_memory_storage_factory(lambda spec: FirestoreMemoryBackend(...))`. Authentication uses Application Default Credentials.
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
- One collection, one document per record (doc id = record id) with the embedding stored as a Firestore `Vector`.
|
|
24
|
+
- `search` uses native `find_nearest` (cosine) after a scope pre-filter. A scope *subtree* is two queries (the exact scope, and the `scope/…` range) merged by similarity; score is `1 - distance`. `categories` / `metadata_filter` / `min_score` are applied on the candidates.
|
|
25
|
+
- Firestore requires a **composite vector index** `scope ASC + embedding (flat, dims)`. With `create_index=True` (default) the backend creates it through the Admin API if missing; the build is asynchronous and vector queries fail with `FAILED_PRECONDITION` until it is READY (a few minutes). Needs `datastore.indexes.create`/`list`, or create it yourself and pass `create_index=False`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gcloud firestore indexes composite create --collection-group=crewai_memory --query-scope=COLLECTION \
|
|
29
|
+
--field-config=order=ASCENDING,field-path=scope \
|
|
30
|
+
--field-config=vector-config='{"dimension":"3072","flat":"{}"}',field-path=embedding
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Docs: <https://skamalj.github.io/agentstate-reducer/> · part of [crewai-memory](https://github.com/skamalj/crewai-memory)
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crewai-memory-firestore"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Google Firestore StorageBackend for CrewAI unified Memory — hierarchical scopes, categories, metadata filters and native vector search (find_nearest)"
|
|
9
|
+
authors = [{name = "Kamal", email = "skamalj@gmail.com"}]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"crewai-memory-core>=0.1.0",
|
|
14
|
+
"google-cloud-firestore>=2.16",
|
|
15
|
+
]
|
|
16
|
+
keywords = ["crewai", "memory", "storage-backend", "firestore", "gcp", "long-term-memory", "agent-memory", "vector-search"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/skamalj/crewai-memory"
|
|
26
|
+
Repository = "https://github.com/skamalj/crewai-memory.git"
|
|
27
|
+
Documentation = "https://skamalj.github.io/agentstate-reducer/"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest>=7.0", "pytest-asyncio"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/crewai_memory_firestore"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Google Firestore ``StorageBackend`` for CrewAI unified ``Memory``.
|
|
2
|
+
|
|
3
|
+
One collection, one document per ``MemoryRecord`` (doc id = record id) with the
|
|
4
|
+
embedding stored as a Firestore ``Vector``. ``search`` uses native
|
|
5
|
+
``find_nearest`` (cosine) after a scope pre-filter; a scope *subtree* needs two
|
|
6
|
+
queries (the exact scope, and the ``scope/…`` range), merged by similarity.
|
|
7
|
+
Built on ``crewai-memory-core``.
|
|
8
|
+
|
|
9
|
+
Firestore needs a composite **vector index** ``scope ASC + embedding (flat)``;
|
|
10
|
+
with ``create_index=True`` (default) it is created through the Admin API if
|
|
11
|
+
missing (asynchronous build — vector queries fail with FAILED_PRECONDITION
|
|
12
|
+
until READY).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from google.cloud import firestore
|
|
21
|
+
from google.cloud.firestore_v1.base_query import FieldFilter
|
|
22
|
+
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
|
|
23
|
+
from google.cloud.firestore_v1.vector import Vector
|
|
24
|
+
|
|
25
|
+
from crewai_memory_core import MemoryBackend, ROW_FIELDS, norm_scope
|
|
26
|
+
|
|
27
|
+
__all__ = ["FirestoreMemoryBackend"]
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
_HIGH = chr(0xF8FF)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FirestoreMemoryBackend(MemoryBackend):
|
|
34
|
+
"""CrewAI ``StorageBackend`` on Firestore with native vector search.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
```python
|
|
38
|
+
from crewai.memory import Memory
|
|
39
|
+
from crewai_memory_firestore import FirestoreMemoryBackend
|
|
40
|
+
|
|
41
|
+
backend = FirestoreMemoryBackend(project_id="my-project", collection="crewai_memory", dimensions=3072)
|
|
42
|
+
memory = Memory(storage=backend)
|
|
43
|
+
```
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
supports_native_vector_search = True
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
project_id: str,
|
|
52
|
+
collection: str = "crewai_memory",
|
|
53
|
+
dimensions: int = 3072,
|
|
54
|
+
client: Optional["firestore.Client"] = None,
|
|
55
|
+
create_index: bool = True,
|
|
56
|
+
database: str = "(default)",
|
|
57
|
+
) -> None:
|
|
58
|
+
self.dimensions = dimensions
|
|
59
|
+
self._project = project_id
|
|
60
|
+
self._database = database
|
|
61
|
+
self._collection = collection
|
|
62
|
+
self._client = client or firestore.Client(project=project_id, database=database)
|
|
63
|
+
self._col = self._client.collection(collection)
|
|
64
|
+
if create_index:
|
|
65
|
+
self._ensure_vector_index()
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------ index bootstrap
|
|
68
|
+
def _ensure_vector_index(self) -> None:
|
|
69
|
+
from google.cloud import firestore_admin_v1 as fa
|
|
70
|
+
|
|
71
|
+
admin = fa.FirestoreAdminClient()
|
|
72
|
+
parent = f"projects/{self._project}/databases/{self._database}/collectionGroups/{self._collection}"
|
|
73
|
+
for ix in admin.list_indexes(parent=parent):
|
|
74
|
+
paths = [f.field_path for f in ix.fields]
|
|
75
|
+
if "embedding" in paths and "scope" in paths:
|
|
76
|
+
return
|
|
77
|
+
index = fa.Index(
|
|
78
|
+
query_scope=fa.Index.QueryScope.COLLECTION,
|
|
79
|
+
fields=[
|
|
80
|
+
fa.Index.IndexField(field_path="scope", order=fa.Index.IndexField.Order.ASCENDING),
|
|
81
|
+
fa.Index.IndexField(
|
|
82
|
+
field_path="embedding",
|
|
83
|
+
vector_config=fa.Index.IndexField.VectorConfig(
|
|
84
|
+
dimension=self.dimensions, flat=fa.Index.IndexField.VectorConfig.FlatIndex()
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
admin.create_index(parent=parent, index=index)
|
|
91
|
+
logger.info("FirestoreMemoryBackend: creating vector index on %s (async)", self._collection)
|
|
92
|
+
except Exception as exc: # already exists / racing creation
|
|
93
|
+
logger.info("FirestoreMemoryBackend: vector index create skipped: %s", exc)
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ helpers
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _row(d: dict) -> dict:
|
|
98
|
+
row = {f: d.get(f) for f in ROW_FIELDS}
|
|
99
|
+
row["scope"] = norm_scope(d.get("scope"))
|
|
100
|
+
emb = d.get("embedding")
|
|
101
|
+
row["embedding"] = list(emb.to_map_value()["value"]) if isinstance(emb, Vector) else (list(emb) if emb else None)
|
|
102
|
+
return row
|
|
103
|
+
|
|
104
|
+
def _scope_queries(self, scope_prefix: Optional[str]):
|
|
105
|
+
"""Firestore has no OR-with-range on one field, so a subtree is two queries."""
|
|
106
|
+
p = norm_scope(scope_prefix)
|
|
107
|
+
if p == "/":
|
|
108
|
+
# Every scope starts with "/", so a range on `scope` selects all rows while
|
|
109
|
+
# still using the scope+embedding composite index (a filter-less
|
|
110
|
+
# find_nearest would need a separate embedding-only index).
|
|
111
|
+
return [self._col.where(filter=FieldFilter("scope", ">=", "/"))]
|
|
112
|
+
return [
|
|
113
|
+
self._col.where(filter=FieldFilter("scope", "==", p)),
|
|
114
|
+
self._col.where(filter=FieldFilter("scope", ">=", p + "/")).where(filter=FieldFilter("scope", "<", p + "/" + _HIGH)),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ primitives
|
|
118
|
+
def _put(self, rows: List[dict]) -> None:
|
|
119
|
+
batch = self._client.batch()
|
|
120
|
+
for row in rows:
|
|
121
|
+
doc = {f: row.get(f) for f in ROW_FIELDS if f != "embedding"}
|
|
122
|
+
doc["scope"] = norm_scope(row["scope"])
|
|
123
|
+
doc["categories"] = list(row.get("categories") or [])
|
|
124
|
+
doc["metadata"] = dict(row.get("metadata") or {})
|
|
125
|
+
doc["private"] = bool(row.get("private", False))
|
|
126
|
+
if row.get("embedding"):
|
|
127
|
+
doc["embedding"] = Vector(row["embedding"])
|
|
128
|
+
batch.set(self._col.document(row["id"]), doc)
|
|
129
|
+
batch.commit()
|
|
130
|
+
|
|
131
|
+
def _get(self, record_id: str) -> Optional[dict]:
|
|
132
|
+
snap = self._col.document(record_id).get()
|
|
133
|
+
return self._row(snap.to_dict()) if snap.exists else None
|
|
134
|
+
|
|
135
|
+
def _delete_ids(self, ids: List[str]) -> int:
|
|
136
|
+
n = 0
|
|
137
|
+
batch = self._client.batch()
|
|
138
|
+
for rid in ids:
|
|
139
|
+
ref = self._col.document(rid)
|
|
140
|
+
if ref.get().exists:
|
|
141
|
+
batch.delete(ref)
|
|
142
|
+
n += 1
|
|
143
|
+
batch.commit()
|
|
144
|
+
return n
|
|
145
|
+
|
|
146
|
+
def _scan(self, scope_prefix: Optional[str]) -> List[dict]:
|
|
147
|
+
rows: List[dict] = []
|
|
148
|
+
for q in self._scope_queries(scope_prefix):
|
|
149
|
+
rows.extend(self._row(d.to_dict()) for d in q.stream())
|
|
150
|
+
return rows
|
|
151
|
+
|
|
152
|
+
def _vector_search(
|
|
153
|
+
self, vector: List[float], scope_prefix: Optional[str], limit: int
|
|
154
|
+
) -> List[Tuple[dict, float]]:
|
|
155
|
+
out: List[Tuple[dict, float]] = []
|
|
156
|
+
for q in self._scope_queries(scope_prefix):
|
|
157
|
+
vq = q.find_nearest(
|
|
158
|
+
vector_field="embedding", query_vector=Vector(vector),
|
|
159
|
+
distance_measure=DistanceMeasure.COSINE, limit=limit, distance_result_field="_distance",
|
|
160
|
+
)
|
|
161
|
+
for d in vq.stream():
|
|
162
|
+
data = d.to_dict()
|
|
163
|
+
dist = data.pop("_distance", None)
|
|
164
|
+
out.append((self._row(data), 1.0 - float(dist) if dist is not None else 0.0))
|
|
165
|
+
out.sort(key=lambda rs: -rs[1])
|
|
166
|
+
return out[:limit]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Firestore StorageBackend: the shared contract suite against a real Firestore database.
|
|
2
|
+
|
|
3
|
+
The backend creates the composite vector index (scope ASC + embedding flat) via
|
|
4
|
+
the Admin API; index builds are asynchronous so the fixture waits for READY and
|
|
5
|
+
the collection name is fixed per DIMS so the index is reused across runs.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from crewai_memory_core.contract import * # noqa: F401,F403
|
|
13
|
+
from crewai_memory_core.contract import DIMS
|
|
14
|
+
from crewai_memory_firestore import FirestoreMemoryBackend
|
|
15
|
+
|
|
16
|
+
PROJECT = os.environ.get("GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT") or "gcdeveloper-new"
|
|
17
|
+
COLLECTION = f"crewai_mem_test_d{DIMS}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _available() -> bool:
|
|
21
|
+
try:
|
|
22
|
+
from google.cloud import firestore
|
|
23
|
+
firestore.Client(project=PROJECT).collection("x").limit(1).get()
|
|
24
|
+
return True
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
pytestmark = pytest.mark.skipif(not _available(), reason="Firestore not reachable")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _wait_index_ready(timeout: int = 600) -> None:
|
|
33
|
+
from google.cloud import firestore_admin_v1 as fa
|
|
34
|
+
admin = fa.FirestoreAdminClient()
|
|
35
|
+
parent = f"projects/{PROJECT}/databases/(default)/collectionGroups/{COLLECTION}"
|
|
36
|
+
deadline = time.time() + timeout
|
|
37
|
+
while time.time() < deadline:
|
|
38
|
+
for ix in admin.list_indexes(parent=parent):
|
|
39
|
+
paths = [f.field_path for f in ix.fields]
|
|
40
|
+
if "embedding" in paths and "scope" in paths and ix.state == fa.Index.State.READY:
|
|
41
|
+
return
|
|
42
|
+
time.sleep(10)
|
|
43
|
+
raise RuntimeError("vector index not READY in time")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture(scope="module")
|
|
47
|
+
def _store():
|
|
48
|
+
b = FirestoreMemoryBackend(project_id=PROJECT, collection=COLLECTION, dimensions=DIMS)
|
|
49
|
+
_wait_index_ready()
|
|
50
|
+
yield b
|
|
51
|
+
b.reset()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture()
|
|
55
|
+
def backend(_store):
|
|
56
|
+
_store.reset()
|
|
57
|
+
yield _store
|
|
58
|
+
_store.reset()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_native_vector_path_used(backend, monkeypatch):
|
|
62
|
+
from crewai_memory_core.contract import _seed, EMB
|
|
63
|
+
_seed(backend)
|
|
64
|
+
called = []
|
|
65
|
+
orig = backend._vector_search
|
|
66
|
+
monkeypatch.setattr(backend, "_vector_search", lambda *a, **k: (called.append(1), orig(*a, **k))[1])
|
|
67
|
+
assert backend.search(EMB(["sushi"])[0], scope_prefix="/crew", limit=5)
|
|
68
|
+
assert called
|