crewai-memory-dynamodb 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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ uv.lock
9
+ .env
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: crewai-memory-dynamodb
3
+ Version: 0.1.0
4
+ Summary: Amazon DynamoDB StorageBackend for CrewAI unified Memory — hierarchical scopes, categories, metadata filters and native vector search (SearchVectors)
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,aws,crewai,dynamodb,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: boto3>=1.43.78
16
+ Requires-Dist: crewai-memory-core>=0.1.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # crewai-memory-dynamodb
20
+
21
+ An **Amazon DynamoDB** `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 DynamoDB `SearchVectors`. No external vector database.
22
+
23
+ ```bash
24
+ pip install crewai-memory-dynamodb
25
+ ```
26
+
27
+ ```python
28
+ from crewai import Crew
29
+ from crewai.memory import Memory
30
+ from crewai_memory_dynamodb import DynamoDBMemoryBackend
31
+
32
+ backend = DynamoDBMemoryBackend(table_name="crewai-memory", dimensions=3072) # match your embedder
33
+ memory = Memory(storage=backend) # CrewAI does LLM analysis, scoping, scoring
34
+ crew = Crew(agents=[...], tasks=[...], memory=memory)
35
+
36
+ memory.remember("The customer prefers email over phone", scope="/customers/acme", categories=["preference"])
37
+ memory.recall("how should we contact acme?", scope="/customers")
38
+ ```
39
+
40
+ Or register it once for every `Crew(memory=True)`:
41
+
42
+ ```python
43
+ from crewai.memory.storage.factory import set_memory_storage_factory
44
+ set_memory_storage_factory(lambda spec: DynamoDBMemoryBackend("crewai-memory", dimensions=3072))
45
+ ```
46
+
47
+ ## How it works
48
+
49
+ - One table: `PK` = scope path, `SK` = record id, plus a `by_id` GSI and a **vector index** on `embedding` (cosine, `dimensions`) whose search schema declares `PK` as an inline filter. Auto-created (`PAY_PER_REQUEST`); the vector index can only be declared at creation, so use a new table to change `dimensions`.
50
+ - `search` runs native `SearchVectors` once per concrete scope under the requested prefix (DynamoDB allows only equality on a string search-schema attribute), merges by similarity (`1 - distance`), then applies `categories` / `metadata_filter` / `min_score` on the candidates (oversampled ×3 when filtering).
51
+ - Scope tree, category counts, `list_records`, `delete(older_than=...)`, `reset`, `touch_records` are query/scan based — sized for agent-memory volumes.
52
+ - `dimensions` must equal the `Memory` embedder's output size (CrewAI default `text-embedding-3-large` = 3072; Bedrock Titan v2 = 1024).
53
+
54
+ Requires `boto3>=1.43.78` and a region where DynamoDB vector search is available. Permissions: `DescribeTable`, `CreateTable`, `GetItem`, `PutItem`, `DeleteItem`, `BatchWriteItem`, `Query`, `Scan`, `SearchVectors`.
55
+
56
+ Docs: <https://skamalj.github.io/agentstate-reducer/> · part of [crewai-memory](https://github.com/skamalj/crewai-memory)
57
+
58
+ ## License
59
+
60
+ MIT
@@ -0,0 +1,42 @@
1
+ # crewai-memory-dynamodb
2
+
3
+ An **Amazon DynamoDB** `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 DynamoDB `SearchVectors`. No external vector database.
4
+
5
+ ```bash
6
+ pip install crewai-memory-dynamodb
7
+ ```
8
+
9
+ ```python
10
+ from crewai import Crew
11
+ from crewai.memory import Memory
12
+ from crewai_memory_dynamodb import DynamoDBMemoryBackend
13
+
14
+ backend = DynamoDBMemoryBackend(table_name="crewai-memory", dimensions=3072) # match your embedder
15
+ memory = Memory(storage=backend) # CrewAI does LLM analysis, scoping, scoring
16
+ crew = Crew(agents=[...], tasks=[...], memory=memory)
17
+
18
+ memory.remember("The customer prefers email over phone", scope="/customers/acme", categories=["preference"])
19
+ memory.recall("how should we contact acme?", scope="/customers")
20
+ ```
21
+
22
+ Or register it once for every `Crew(memory=True)`:
23
+
24
+ ```python
25
+ from crewai.memory.storage.factory import set_memory_storage_factory
26
+ set_memory_storage_factory(lambda spec: DynamoDBMemoryBackend("crewai-memory", dimensions=3072))
27
+ ```
28
+
29
+ ## How it works
30
+
31
+ - One table: `PK` = scope path, `SK` = record id, plus a `by_id` GSI and a **vector index** on `embedding` (cosine, `dimensions`) whose search schema declares `PK` as an inline filter. Auto-created (`PAY_PER_REQUEST`); the vector index can only be declared at creation, so use a new table to change `dimensions`.
32
+ - `search` runs native `SearchVectors` once per concrete scope under the requested prefix (DynamoDB allows only equality on a string search-schema attribute), merges by similarity (`1 - distance`), then applies `categories` / `metadata_filter` / `min_score` on the candidates (oversampled ×3 when filtering).
33
+ - Scope tree, category counts, `list_records`, `delete(older_than=...)`, `reset`, `touch_records` are query/scan based — sized for agent-memory volumes.
34
+ - `dimensions` must equal the `Memory` embedder's output size (CrewAI default `text-embedding-3-large` = 3072; Bedrock Titan v2 = 1024).
35
+
36
+ Requires `boto3>=1.43.78` and a region where DynamoDB vector search is available. Permissions: `DescribeTable`, `CreateTable`, `GetItem`, `PutItem`, `DeleteItem`, `BatchWriteItem`, `Query`, `Scan`, `SearchVectors`.
37
+
38
+ Docs: <https://skamalj.github.io/agentstate-reducer/> · part of [crewai-memory](https://github.com/skamalj/crewai-memory)
39
+
40
+ ## License
41
+
42
+ MIT
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "crewai-memory-dynamodb"
7
+ version = "0.1.0"
8
+ description = "Amazon DynamoDB StorageBackend for CrewAI unified Memory — hierarchical scopes, categories, metadata filters and native vector search (SearchVectors)"
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
+ "boto3>=1.43.78",
15
+ ]
16
+ keywords = ["crewai", "memory", "storage-backend", "dynamodb", "aws", "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_dynamodb"]
@@ -0,0 +1,216 @@
1
+ """Amazon DynamoDB ``StorageBackend`` for CrewAI unified ``Memory``.
2
+
3
+ One table, ``PK`` = scope path, ``SK`` = record id, with a DynamoDB **vector
4
+ index** on ``embedding`` (cosine) whose search schema declares ``PK`` as an
5
+ inline filter. ``search`` runs DynamoDB's native ``SearchVectors`` — one call
6
+ per concrete scope under the requested prefix (DynamoDB allows only equality on
7
+ a string search-schema attribute) — and the core merges by similarity and
8
+ applies category / metadata filters. Built on ``crewai-memory-core``.
9
+
10
+ Requires ``boto3>=1.43.78`` and a region where DynamoDB vector search is
11
+ available. The vector index can only be declared at table creation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from decimal import Decimal
17
+ from typing import Any, List, Optional, Tuple
18
+
19
+ import boto3
20
+ from boto3.dynamodb.conditions import Attr, Key
21
+ from boto3.dynamodb.types import TypeDeserializer
22
+
23
+ from crewai_memory_core import MemoryBackend, ROW_FIELDS, in_scope, norm_scope
24
+
25
+ __all__ = ["DynamoDBMemoryBackend"]
26
+
27
+
28
+ def _plain(obj: Any) -> Any:
29
+ if isinstance(obj, Decimal):
30
+ return int(obj) if obj == obj.to_integral_value() else float(obj)
31
+ if isinstance(obj, dict):
32
+ return {k: _plain(v) for k, v in obj.items()}
33
+ if isinstance(obj, list):
34
+ return [_plain(v) for v in obj]
35
+ return obj
36
+
37
+
38
+ def _ddb(obj: Any) -> Any:
39
+ if isinstance(obj, float):
40
+ return Decimal(repr(obj))
41
+ if isinstance(obj, dict):
42
+ return {k: _ddb(v) for k, v in obj.items()}
43
+ if isinstance(obj, list):
44
+ return [_ddb(v) for v in obj]
45
+ return obj
46
+
47
+
48
+ class DynamoDBMemoryBackend(MemoryBackend):
49
+ """CrewAI ``StorageBackend`` on DynamoDB with native vector search.
50
+
51
+ Example:
52
+ ```python
53
+ from crewai.memory import Memory
54
+ from crewai_memory_dynamodb import DynamoDBMemoryBackend
55
+
56
+ backend = DynamoDBMemoryBackend(table_name="crewai-memory", dimensions=3072)
57
+ memory = Memory(storage=backend) # embedder default: OpenAI text-embedding-3-large (3072)
58
+ ```
59
+
60
+ ``dimensions`` must match the embedder configured on ``Memory``.
61
+ """
62
+
63
+ supports_native_vector_search = True
64
+
65
+ def __init__(
66
+ self,
67
+ table_name: str,
68
+ *,
69
+ dimensions: int = 3072,
70
+ index_name: str = "vector_index",
71
+ region_name: Optional[str] = None,
72
+ boto_session: Optional["boto3.Session"] = None,
73
+ endpoint_url: Optional[str] = None,
74
+ ) -> None:
75
+ session = boto_session or boto3.Session(region_name=region_name)
76
+ self._client = session.client("dynamodb", endpoint_url=endpoint_url)
77
+ self._resource = session.resource("dynamodb", endpoint_url=endpoint_url)
78
+ self.table_name = table_name
79
+ self.dimensions = dimensions
80
+ self._index_name = index_name
81
+ self._deser = TypeDeserializer()
82
+ self._ensure_table()
83
+ self.table = self._resource.Table(table_name)
84
+
85
+ # ------------------------------------------------------------------ setup
86
+ def _ensure_table(self) -> None:
87
+ try:
88
+ self._client.describe_table(TableName=self.table_name)
89
+ return
90
+ except self._client.exceptions.ResourceNotFoundException:
91
+ pass
92
+ self._client.create_table(
93
+ TableName=self.table_name,
94
+ KeySchema=[
95
+ {"AttributeName": "PK", "KeyType": "HASH"},
96
+ {"AttributeName": "SK", "KeyType": "RANGE"},
97
+ ],
98
+ AttributeDefinitions=[
99
+ {"AttributeName": "PK", "AttributeType": "S"},
100
+ {"AttributeName": "SK", "AttributeType": "S"},
101
+ ],
102
+ BillingMode="PAY_PER_REQUEST",
103
+ GlobalSecondaryIndexes=[
104
+ {
105
+ "IndexName": "by_id",
106
+ "KeySchema": [{"AttributeName": "SK", "KeyType": "HASH"}],
107
+ "Projection": {"ProjectionType": "ALL"},
108
+ }
109
+ ],
110
+ VectorIndexes=[
111
+ {
112
+ "IndexName": self._index_name,
113
+ "VectorAttribute": {"AttributeName": "embedding"},
114
+ "Dimensions": self.dimensions,
115
+ "DistanceFunction": "COSINE",
116
+ "Projection": {"ProjectionType": "ALL"},
117
+ "SearchSchema": [{"AttributeName": "PK", "SearchSchemaElementType": "INLINE_FILTER"}],
118
+ }
119
+ ],
120
+ )
121
+ self._client.get_waiter("table_exists").wait(TableName=self.table_name)
122
+
123
+ # ------------------------------------------------------------------ rows
124
+ def _to_row(self, item: dict) -> dict:
125
+ item = _plain(item)
126
+ row = {f: item.get(f) for f in ROW_FIELDS}
127
+ row["id"] = item["SK"]
128
+ row["scope"] = item["PK"]
129
+ row["embedding"] = [float(v) for v in item["embedding"]] if item.get("embedding") else None
130
+ return row
131
+
132
+ def _to_item(self, row: dict) -> dict:
133
+ item = {"PK": norm_scope(row["scope"]), "SK": row["id"]}
134
+ for f in ROW_FIELDS:
135
+ if f in ("id", "scope", "embedding"):
136
+ continue
137
+ if row.get(f) is not None:
138
+ item[f] = _ddb(row[f])
139
+ if row.get("embedding"):
140
+ item["embedding"] = [Decimal(repr(float(v))) for v in row["embedding"]]
141
+ return item
142
+
143
+ def _paginate(self, fn, **kwargs) -> List[dict]:
144
+ items: List[dict] = []
145
+ while True:
146
+ resp = fn(**kwargs)
147
+ items.extend(resp.get("Items", []))
148
+ lek = resp.get("LastEvaluatedKey")
149
+ if not lek:
150
+ return items
151
+ kwargs["ExclusiveStartKey"] = lek
152
+
153
+ # ------------------------------------------------------------------ primitives
154
+ def _put(self, rows: List[dict]) -> None:
155
+ with self.table.batch_writer() as bw:
156
+ for row in rows:
157
+ existing = self._get(row["id"])
158
+ if existing and norm_scope(existing["scope"]) != norm_scope(row["scope"]):
159
+ bw.delete_item(Key={"PK": existing["scope"], "SK": row["id"]}) # scope moved
160
+ bw.put_item(Item=self._to_item(row))
161
+
162
+ def _get(self, record_id: str) -> Optional[dict]:
163
+ resp = self.table.query(IndexName="by_id", KeyConditionExpression=Key("SK").eq(record_id), Limit=1)
164
+ items = resp.get("Items", [])
165
+ return self._to_row(items[0]) if items else None
166
+
167
+ def _delete_ids(self, ids: List[str]) -> int:
168
+ n = 0
169
+ with self.table.batch_writer() as bw:
170
+ for rid in ids:
171
+ row = self._get(rid)
172
+ if row:
173
+ bw.delete_item(Key={"PK": row["scope"], "SK": rid})
174
+ n += 1
175
+ return n
176
+
177
+ def _scan(self, scope_prefix: Optional[str]) -> List[dict]:
178
+ p = norm_scope(scope_prefix)
179
+ if p == "/":
180
+ items = self._paginate(self.table.scan)
181
+ else:
182
+ items = self._paginate(self.table.query, KeyConditionExpression=Key("PK").eq(p))
183
+ items += self._paginate(self.table.scan, FilterExpression=Attr("PK").begins_with(p + "/"))
184
+ return [self._to_row(i) for i in items]
185
+
186
+ def _scopes_under(self, scope_prefix: Optional[str]) -> List[str]:
187
+ p = norm_scope(scope_prefix)
188
+ kwargs: dict = {"ProjectionExpression": "PK"}
189
+ if p != "/":
190
+ kwargs["FilterExpression"] = Attr("PK").eq(p) | Attr("PK").begins_with(p + "/")
191
+ return sorted({i["PK"] for i in self._paginate(self.table.scan, **kwargs)})
192
+
193
+ def _vector_search(
194
+ self, vector: List[float], scope_prefix: Optional[str], limit: int
195
+ ) -> List[Tuple[dict, float]]:
196
+ scopes = self._scopes_under(scope_prefix)
197
+ if not scopes:
198
+ return []
199
+ qv = [{"N": repr(float(v))} for v in vector]
200
+ out: List[Tuple[dict, float]] = []
201
+ for sc in scopes:
202
+ resp = self._client.search_vectors(
203
+ TableName=self.table_name,
204
+ IndexName=self._index_name,
205
+ SearchVector=qv,
206
+ TopK=int(limit),
207
+ SearchConditionExpression="#pk = :s",
208
+ ExpressionAttributeNames={"#pk": "PK"},
209
+ ExpressionAttributeValues={":s": {"S": sc}},
210
+ )
211
+ for r in resp.get("SearchResults", []):
212
+ item = {k: self._deser.deserialize(v) for k, v in r["Item"].items()}
213
+ # DynamoDB returns the cosine *distance* (0 = identical).
214
+ out.append((self._to_row(item), 1.0 - float(r.get("Score", 1.0))))
215
+ out.sort(key=lambda rs: -rs[1])
216
+ return out[:limit]
@@ -0,0 +1,66 @@
1
+ """DynamoDB StorageBackend: the shared contract suite against a real table (us-east-1)."""
2
+ import os
3
+ import time
4
+ import uuid
5
+
6
+ import boto3
7
+ import pytest
8
+
9
+ from crewai_memory_core.contract import * # noqa: F401,F403
10
+ from crewai_memory_core.contract import DIMS
11
+ from crewai_memory_dynamodb import DynamoDBMemoryBackend
12
+
13
+ REGION = os.environ.get("CREWAI_DDB_REGION", "us-east-1")
14
+
15
+
16
+ def _aws_available() -> bool:
17
+ try:
18
+ boto3.client("sts", region_name=REGION).get_caller_identity()
19
+ return True
20
+ except Exception:
21
+ return False
22
+
23
+
24
+ pytestmark = pytest.mark.skipif(not _aws_available(), reason="AWS credentials not available")
25
+
26
+
27
+ @pytest.fixture(scope="module")
28
+ def _table():
29
+ name = f"crewai_mem_test_{uuid.uuid4().hex[:8]}"
30
+ b = DynamoDBMemoryBackend(name, dimensions=DIMS, region_name=REGION)
31
+ ddb = boto3.client("dynamodb", region_name=REGION)
32
+ for _ in range(60):
33
+ d = ddb.describe_table(TableName=name)["Table"]
34
+ vi = d.get("VectorIndexes") or []
35
+ gsi = d.get("GlobalSecondaryIndexes") or []
36
+ if vi and all(v.get("IndexStatus", "ACTIVE") == "ACTIVE" for v in vi) and all(g.get("IndexStatus") == "ACTIVE" for g in gsi):
37
+ break
38
+ time.sleep(5)
39
+ yield b
40
+ try:
41
+ ddb.delete_table(TableName=name)
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ @pytest.fixture()
47
+ def backend(_table):
48
+ _table.reset()
49
+ yield _table
50
+ _table.reset()
51
+
52
+
53
+ def test_native_vector_path_used(backend, monkeypatch):
54
+ from crewai_memory_core.contract import _seed, EMB
55
+ _seed(backend)
56
+ calls = []
57
+ orig = backend._client.search_vectors
58
+
59
+ def spy(**kw):
60
+ calls.append(kw["SearchConditionExpression"])
61
+ return orig(**kw)
62
+
63
+ monkeypatch.setattr(backend._client, "search_vectors", spy)
64
+ hits = backend.search(EMB(["sushi"])[0], scope_prefix="/crew/support/user", limit=5)
65
+ assert calls and all(c == "#pk = :s" for c in calls) and len(calls) == 2 # one call per child scope
66
+ assert hits