agentic-composite-memory 0.1.0__py3-none-any.whl

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,309 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Sequence
7
+
8
+ from ..auth import AwsIamDatabaseAuthProvider
9
+ from ..models import (
10
+ GraphEdgeMutation,
11
+ GraphFactReadResult,
12
+ GraphVertexUpsert,
13
+ PlatformTenantContext,
14
+ ReasoningTraceStep,
15
+ )
16
+ from ..ports import GraphStoreAdapter
17
+
18
+
19
+ class NeptuneOpenCypherAdapter(GraphStoreAdapter):
20
+ """
21
+ Production Amazon Neptune adapter using parameterized openCypher
22
+ with mandatory tenant/namespace guards, idempotent batch MERGE,
23
+ bi-temporal functional edge invalidation, and reasoning trace recording.
24
+ Supports AWS IAM SigV4 and cross-account STS AssumeRole via `aws_auth_provider`.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ endpoint_by_tenant: dict[str, str],
30
+ region: str = "us-east-1",
31
+ aws_auth_provider: AwsIamDatabaseAuthProvider | None = None,
32
+ ):
33
+ self.endpoints = endpoint_by_tenant
34
+ self.region = region
35
+ self.aws_auth_provider = aws_auth_provider
36
+ self._clients: dict[str, Any] = {}
37
+
38
+ def _client(self, tenant_id: str):
39
+ import boto3
40
+
41
+ ep = self.endpoints.get(tenant_id) or self.endpoints["default"]
42
+ if ep not in self._clients:
43
+ endpoint_url = ep if ep.startswith("http") else f"https://{ep}:8182"
44
+ session = (
45
+ self.aws_auth_provider.get_boto3_session()
46
+ if self.aws_auth_provider is not None
47
+ else boto3.Session(region_name=self.region)
48
+ )
49
+ self._clients[ep] = session.client(
50
+ "neptunedata",
51
+ region_name=self.region,
52
+ endpoint_url=endpoint_url,
53
+ )
54
+ return self._clients[ep]
55
+
56
+ async def _run_cypher(
57
+ self, tenant_id: str, query: str, params: dict[str, Any]
58
+ ) -> list[dict[str, Any]]:
59
+ client = self._client(tenant_id)
60
+ resp = await asyncio.to_thread(
61
+ client.execute_open_cypher_query,
62
+ openCypherQuery=query,
63
+ parameters=json.dumps(params),
64
+ )
65
+ return resp.get("results", [])
66
+
67
+ async def upsert_vertices_and_bitemporal_edges(
68
+ self,
69
+ tenant_id: str,
70
+ vertices: Sequence[GraphVertexUpsert],
71
+ edges: Sequence[GraphEdgeMutation],
72
+ ) -> None:
73
+ if vertices:
74
+ vertex_dicts = [v.model_dump(exclude={"embedding"}) for v in vertices]
75
+ await self._run_cypher(
76
+ tenant_id,
77
+ """
78
+ UNWIND $vertices AS v
79
+ MERGE (n:Entity {`~id`: v.vertex_id, tenant_id: v.tenant_id})
80
+ ON CREATE SET n.canonical_name = v.canonical_name,
81
+ n.entity_class = v.entity_class,
82
+ n.upper_class = v.upper_class,
83
+ n.summary = v.summary,
84
+ n.namespace = v.namespace,
85
+ n.updated_at = v.updated_at
86
+ ON MATCH SET n.summary = v.summary,
87
+ n.updated_at = v.updated_at
88
+ """,
89
+ {"vertices": vertex_dicts},
90
+ )
91
+
92
+ for e in edges:
93
+ if e.is_functional:
94
+ # Close t_invalid on any prior active functional edge with a different target
95
+ await self._run_cypher(
96
+ tenant_id,
97
+ """
98
+ MATCH (s:Entity {`~id`: $src_id, tenant_id: $tenant_id})
99
+ -[r:RELATES {predicate: $pred, namespace: $ns, t_invalid: 'ACTIVE'}]->
100
+ (old_t:Entity)
101
+ WHERE old_t.`~id` <> $tgt_id
102
+ SET r.t_invalid = $t_valid
103
+ """,
104
+ {
105
+ "src_id": e.source_vertex_id,
106
+ "tenant_id": tenant_id,
107
+ "pred": e.predicate,
108
+ "ns": e.namespace,
109
+ "tgt_id": e.target_vertex_id,
110
+ "t_valid": e.t_valid,
111
+ },
112
+ )
113
+
114
+ await self._run_cypher(
115
+ tenant_id,
116
+ """
117
+ MATCH (s:Entity {`~id`: $src_id, tenant_id: $tenant_id})
118
+ MATCH (t:Entity {`~id`: $tgt_id, tenant_id: $tenant_id})
119
+ MERGE (s)-[r:RELATES {
120
+ predicate: $pred,
121
+ namespace: $ns,
122
+ tenant_id: $tenant_id,
123
+ t_invalid: 'ACTIVE'
124
+ }]->(t)
125
+ ON CREATE SET r.edge_id = $edge_id,
126
+ r.t_valid = $t_valid,
127
+ r.t_created = $t_created,
128
+ r.fact_summary = $summary,
129
+ r.confidence = $conf,
130
+ r.reinforcement_count = 1,
131
+ r.source_episode_id = $ep_id,
132
+ r.source_app = $app,
133
+ r.user_id = $uid
134
+ ON MATCH SET r.fact_summary = $summary,
135
+ r.reinforcement_count = coalesce(r.reinforcement_count, 1) + 1,
136
+ r.confidence = CASE
137
+ WHEN coalesce(r.confidence, 0.9) + 0.03 > 1.0 THEN 1.0
138
+ ELSE coalesce(r.confidence, 0.9) + 0.03
139
+ END,
140
+ r.updated_at = $t_valid
141
+ """,
142
+ {
143
+ "src_id": e.source_vertex_id,
144
+ "tgt_id": e.target_vertex_id,
145
+ "tenant_id": tenant_id,
146
+ "pred": e.predicate,
147
+ "ns": e.namespace,
148
+ "edge_id": e.edge_id,
149
+ "t_valid": e.t_valid,
150
+ "t_created": e.t_created,
151
+ "summary": e.fact_summary,
152
+ "conf": e.confidence,
153
+ "ep_id": e.source_episode_id,
154
+ "app": e.source_app,
155
+ "uid": e.user_id,
156
+ },
157
+ )
158
+
159
+ async def expand_active_subgraph(
160
+ self,
161
+ ctx: PlatformTenantContext,
162
+ seed_vertex_ids: Sequence[str],
163
+ as_of_iso: str | None = None,
164
+ max_hops: int = 2,
165
+ limit: int = 25,
166
+ ) -> list[GraphFactReadResult]:
167
+ if not seed_vertex_ids:
168
+ return []
169
+
170
+ if as_of_iso is None:
171
+ temporal_filter = "r.t_invalid = 'ACTIVE'"
172
+ else:
173
+ temporal_filter = (
174
+ "r.t_valid <= $as_of AND (r.t_invalid = 'ACTIVE' OR r.t_invalid > $as_of)"
175
+ )
176
+
177
+ frontier = list(dict.fromkeys(seed_vertex_ids))
178
+ visited_vertices = set(frontier)
179
+ seen_edges: set[tuple[str, str, str, str, str]] = set()
180
+ out: list[GraphFactReadResult] = []
181
+
182
+ for _hop in range(max(1, max_hops)):
183
+ if not frontier or len(out) >= limit:
184
+ break
185
+
186
+ results = await self._run_cypher(
187
+ ctx.tenant_id,
188
+ f"""
189
+ MATCH (s:Entity)-[r:RELATES]->(t:Entity)
190
+ WHERE (s.`~id` IN $seed_ids OR t.`~id` IN $seed_ids)
191
+ AND s.tenant_id = $tenant_id
192
+ AND t.tenant_id = $tenant_id
193
+ AND s.namespace IN $read_ns
194
+ AND t.namespace IN $read_ns
195
+ AND r.namespace IN $read_ns
196
+ AND {temporal_filter}
197
+ RETURN s.`~id` AS src_id,
198
+ s.canonical_name AS src,
199
+ coalesce(s.entity_class, 'p:Concept') AS src_cls,
200
+ r.predicate AS pred,
201
+ t.`~id` AS tgt_id,
202
+ t.canonical_name AS tgt,
203
+ coalesce(t.entity_class, 'p:Concept') AS tgt_cls,
204
+ r.fact_summary AS summary,
205
+ r.t_valid AS t_valid,
206
+ r.t_invalid AS t_invalid,
207
+ r.namespace AS ns,
208
+ coalesce(r.source_episode_id, '') AS ep_id,
209
+ coalesce(r.confidence, 1.0) AS conf
210
+ LIMIT $limit
211
+ """,
212
+ {
213
+ "seed_ids": frontier,
214
+ "tenant_id": ctx.tenant_id,
215
+ "read_ns": ctx.read_namespaces,
216
+ "as_of": as_of_iso or "",
217
+ "limit": limit,
218
+ },
219
+ )
220
+ next_frontier: list[str] = []
221
+ for r in results:
222
+ edge_key = (
223
+ r["src_id"],
224
+ r["pred"],
225
+ r["tgt_id"],
226
+ r["ns"],
227
+ r["t_valid"],
228
+ )
229
+ if edge_key in seen_edges:
230
+ continue
231
+ seen_edges.add(edge_key)
232
+ out.append(
233
+ GraphFactReadResult(
234
+ source_name=r["src"],
235
+ source_class=r.get("src_cls", "p:Concept"),
236
+ predicate=r["pred"],
237
+ target_name=r["tgt"],
238
+ target_class=r.get("tgt_cls", "p:Concept"),
239
+ fact_summary=r["summary"],
240
+ t_valid=r["t_valid"],
241
+ t_invalid=r["t_invalid"],
242
+ namespace=r["ns"],
243
+ source_episode_id=r.get("ep_id", ""),
244
+ confidence=float(r["conf"]),
245
+ )
246
+ )
247
+ for vid in (r["src_id"], r["tgt_id"]):
248
+ if vid not in visited_vertices:
249
+ visited_vertices.add(vid)
250
+ next_frontier.append(vid)
251
+ if len(out) >= limit:
252
+ break
253
+ frontier = next_frontier
254
+
255
+ return out[:limit]
256
+
257
+ async def record_reasoning_trace(
258
+ self,
259
+ ctx: PlatformTenantContext,
260
+ run_id: str,
261
+ session_id: str,
262
+ steps: Sequence[ReasoningTraceStep],
263
+ ) -> None:
264
+ step_dicts = [s.model_dump() for s in steps]
265
+ await self._run_cypher(
266
+ ctx.tenant_id,
267
+ """
268
+ MERGE (run:AgentRun {`~id`: $run_id, tenant_id: $tenant_id})
269
+ ON CREATE SET run.app_name = $app,
270
+ run.session_id = $session_id,
271
+ run.namespace = $ns,
272
+ run.created_at = $now
273
+ WITH run
274
+ UNWIND $steps AS st
275
+ MERGE (tc:ToolCall {`~id`: $run_id + '_' + toString(st.step_index), tenant_id: $tenant_id})
276
+ SET tc.tool_name = st.tool_name,
277
+ tc.input_summary = st.tool_input_summary,
278
+ tc.output_summary = st.tool_output_summary,
279
+ tc.status = st.status,
280
+ tc.namespace = $ns
281
+ MERGE (run)-[:EXECUTED_STEP {step_index: st.step_index}]->(tc)
282
+ WITH tc, st
283
+ UNWIND st.touched_vertex_ids AS vid
284
+ MATCH (e:Entity {`~id`: vid, tenant_id: $tenant_id})
285
+ MERGE (tc)-[:TOUCHED_ENTITY]->(e)
286
+ """,
287
+ {
288
+ "run_id": run_id,
289
+ "tenant_id": ctx.tenant_id,
290
+ "app": ctx.app_name,
291
+ "session_id": session_id,
292
+ "ns": ctx.app_private_namespace,
293
+ "now": datetime.now(timezone.utc).isoformat(),
294
+ "steps": step_dicts,
295
+ },
296
+ )
297
+
298
+ async def purge_episode_lineage(
299
+ self, tenant_id: str, episode_ids: Sequence[str], user_id: str
300
+ ) -> None:
301
+ await self._run_cypher(
302
+ tenant_id,
303
+ """
304
+ MATCH ()-[r:RELATES {tenant_id: $tenant_id}]->()
305
+ WHERE r.source_episode_id IN $ep_ids OR r.user_id = $uid
306
+ DELETE r
307
+ """,
308
+ {"tenant_id": tenant_id, "ep_ids": list(episode_ids), "uid": user_id},
309
+ )
@@ -0,0 +1,322 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any, Sequence
5
+
6
+ from ..governance import EntityResolutionGuard
7
+ from ..models import (
8
+ EpisodicRecord,
9
+ GraphVertexUpsert,
10
+ OutboxOperation,
11
+ PlatformTenantContext,
12
+ )
13
+ from ..ports import EpisodicAndSeedStoreAdapter
14
+
15
+
16
+ class OpenSearchSeedAdapter(EpisodicAndSeedStoreAdapter):
17
+ """
18
+ Swappable Amazon OpenSearch adapter for Tier 1 Episodic Search and Tier 2 Hybrid
19
+ (BM25 + k-NN HNSW) Entry-Point Seeding & Entity Resolution Blocking.
20
+ Enforces tenant_id and namespace isolation filters on every query and write.
21
+ Can be paired with PostgreSQL (RegistryStoreAdapter) and Amazon Neptune (GraphStoreAdapter).
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ hosts: list[dict[str, Any]] | list[str],
27
+ http_auth: tuple[str, str] | Any = None,
28
+ seed_index_name: str = "platform_graph_seeds",
29
+ episodic_index_name: str = "platform_episodic_memories",
30
+ outbox_index_name: str = "platform_graph_outbox",
31
+ use_ssl: bool = True,
32
+ ):
33
+ self.hosts = hosts
34
+ self.http_auth = http_auth
35
+ self.seed_index = seed_index_name
36
+ self.episodic_index = episodic_index_name
37
+ self.outbox_index = outbox_index_name
38
+ self.use_ssl = use_ssl
39
+ self._client: Any = None
40
+
41
+ def _get_client(self):
42
+ if self._client is None:
43
+ from opensearchpy import AsyncOpenSearch
44
+
45
+ self._client = AsyncOpenSearch(
46
+ hosts=self.hosts,
47
+ http_auth=self.http_auth,
48
+ use_ssl=self.use_ssl,
49
+ verify_certs=self.use_ssl,
50
+ )
51
+ return self._client
52
+
53
+ async def close(self) -> None:
54
+ if self._client is not None:
55
+ await self._client.close()
56
+ self._client = None
57
+
58
+ async def resolve_existing_vertex_id(
59
+ self,
60
+ ctx: PlatformTenantContext,
61
+ entity_class: str,
62
+ canonical_name: str,
63
+ embedding: list[float],
64
+ similarity_threshold: float = 0.91,
65
+ ) -> str | None:
66
+ client = self._get_client()
67
+ query_body = {
68
+ "size": 1,
69
+ "query": {
70
+ "bool": {
71
+ "filter": [
72
+ {"term": {"tenant_id": ctx.tenant_id}},
73
+ {"terms": {"namespace": ctx.read_namespaces}},
74
+ {"term": {"entity_class": entity_class}},
75
+ ],
76
+ "should": [
77
+ {"term": {"canonical_name.keyword": canonical_name}},
78
+ {
79
+ "knn": {
80
+ "embedding": {
81
+ "vector": embedding,
82
+ "k": 3,
83
+ }
84
+ }
85
+ },
86
+ ],
87
+ "minimum_should_match": 1,
88
+ }
89
+ },
90
+ }
91
+ resp = await client.search(index=self.seed_index, body=query_body)
92
+ hits = resp.get("hits", {}).get("hits", [])
93
+ for hit in hits:
94
+ src = hit["_source"]
95
+ cand_name = src.get("canonical_name", "")
96
+ score = float(hit.get("_score", 0.0))
97
+ if EntityResolutionGuard.can_merge(
98
+ cand_name, canonical_name, score, similarity_threshold
99
+ ):
100
+ return src["vertex_id"]
101
+ return None
102
+
103
+ async def commit_episodes_and_outbox(
104
+ self,
105
+ ctx: PlatformTenantContext,
106
+ episodes: Sequence[EpisodicRecord],
107
+ vertex_upserts: Sequence[GraphVertexUpsert],
108
+ outbox_op: OutboxOperation | None,
109
+ ) -> None:
110
+ client = self._get_client()
111
+ allowed_write_ns = {ctx.write_namespace, ctx.app_private_namespace}
112
+
113
+ bulk_ops: list[dict[str, Any]] = []
114
+ for ep in episodes:
115
+ if ep.tenant_id != ctx.tenant_id or ep.app_name != ctx.app_name:
116
+ raise PermissionError("OpenSearch episodic write tenant/app isolation error")
117
+ bulk_ops.append({"index": {"_index": self.episodic_index, "_id": ep.episode_id}})
118
+ bulk_ops.append(
119
+ {
120
+ "episode_id": ep.episode_id,
121
+ "tenant_id": ep.tenant_id,
122
+ "app_name": ep.app_name,
123
+ "user_id": ep.user_id,
124
+ "session_id": ep.session_id,
125
+ "namespace": ep.namespace,
126
+ "author": ep.author,
127
+ "content_text": ep.content_text,
128
+ "embedding": ep.embedding,
129
+ "created_at": ep.created_at.isoformat(),
130
+ "metadata": ep.metadata,
131
+ }
132
+ )
133
+
134
+ for v in vertex_upserts:
135
+ if v.tenant_id != ctx.tenant_id or v.namespace not in allowed_write_ns:
136
+ raise PermissionError(
137
+ f"OpenSearch seed write namespace error: {v.namespace}"
138
+ )
139
+ doc_id = f"{v.tenant_id}:{v.vertex_id}"
140
+ bulk_ops.append({"index": {"_index": self.seed_index, "_id": doc_id}})
141
+ bulk_ops.append(v.model_dump())
142
+
143
+ if outbox_op is not None:
144
+ bulk_ops.append(
145
+ {"index": {"_index": self.outbox_index, "_id": outbox_op.outbox_id}}
146
+ )
147
+ bulk_ops.append(
148
+ {
149
+ **outbox_op.model_dump(),
150
+ "status": "PENDING",
151
+ "created_at": datetime.now(timezone.utc).isoformat(),
152
+ }
153
+ )
154
+
155
+ if bulk_ops:
156
+ await client.bulk(body=bulk_ops, refresh="wait_for")
157
+
158
+ async def hybrid_seed_search(
159
+ self,
160
+ ctx: PlatformTenantContext,
161
+ query_text: str,
162
+ query_embedding: list[float],
163
+ limit: int = 8,
164
+ ) -> list[GraphVertexUpsert]:
165
+ client = self._get_client()
166
+ security_filter = [
167
+ {"term": {"tenant_id": ctx.tenant_id}},
168
+ {"terms": {"namespace": ctx.read_namespaces}},
169
+ ]
170
+ # Execute BM25 lexical search + k-NN vector search and fuse with RRF
171
+ body = {
172
+ "size": limit,
173
+ "query": {
174
+ "bool": {
175
+ "filter": security_filter,
176
+ "should": [
177
+ {
178
+ "multi_match": {
179
+ "query": query_text,
180
+ "fields": ["canonical_name^2.0", "summary"],
181
+ }
182
+ },
183
+ {
184
+ "knn": {
185
+ "embedding": {
186
+ "vector": query_embedding,
187
+ "k": limit * 2,
188
+ }
189
+ }
190
+ },
191
+ ],
192
+ "minimum_should_match": 1,
193
+ }
194
+ },
195
+ "_source": {"excludes": ["embedding"]},
196
+ }
197
+ resp = await client.search(index=self.seed_index, body=body)
198
+ hits = resp.get("hits", {}).get("hits", [])
199
+ return [
200
+ GraphVertexUpsert(**{**h["_source"], "embedding": []})
201
+ for h in hits
202
+ ]
203
+
204
+ async def search_episodes(
205
+ self,
206
+ ctx: PlatformTenantContext,
207
+ query_embedding: list[float],
208
+ limit: int = 4,
209
+ ) -> list[EpisodicRecord]:
210
+ client = self._get_client()
211
+ body = {
212
+ "size": limit,
213
+ "query": {
214
+ "bool": {
215
+ "filter": [
216
+ {"term": {"tenant_id": ctx.tenant_id}},
217
+ {"term": {"app_name": ctx.app_name}},
218
+ {"term": {"user_id": ctx.user_id}},
219
+ ],
220
+ "must": [
221
+ {
222
+ "knn": {
223
+ "embedding": {
224
+ "vector": query_embedding,
225
+ "k": limit * 2,
226
+ }
227
+ }
228
+ }
229
+ ],
230
+ }
231
+ },
232
+ "_source": {"excludes": ["embedding"]},
233
+ }
234
+ resp = await client.search(index=self.episodic_index, body=body)
235
+ hits = resp.get("hits", {}).get("hits", [])
236
+ return [
237
+ EpisodicRecord(**{**h["_source"], "embedding": []})
238
+ for h in hits
239
+ ]
240
+
241
+ async def fetch_pending_outbox(self, batch_size: int = 25) -> list[OutboxOperation]:
242
+ client = self._get_client()
243
+ body = {
244
+ "size": batch_size,
245
+ "sort": [{"created_at": {"order": "asc"}}],
246
+ "query": {
247
+ "bool": {
248
+ "filter": [
249
+ {"terms": {"status": ["PENDING", "RETRY"]}},
250
+ {"range": {"attempts": {"lt": 5}}},
251
+ ]
252
+ }
253
+ },
254
+ }
255
+ resp = await client.search(index=self.outbox_index, body=body)
256
+ hits = resp.get("hits", {}).get("hits", [])
257
+ return [
258
+ OutboxOperation(
259
+ outbox_id=h["_source"]["outbox_id"],
260
+ tenant_id=h["_source"]["tenant_id"],
261
+ operation_type=h["_source"]["operation_type"],
262
+ payload=h["_source"]["payload"],
263
+ attempts=h["_source"].get("attempts", 0),
264
+ )
265
+ for h in hits
266
+ ]
267
+
268
+ async def mark_outbox_processed(self, outbox_id: str) -> None:
269
+ client = self._get_client()
270
+ await client.update(
271
+ index=self.outbox_index,
272
+ id=outbox_id,
273
+ body={
274
+ "doc": {
275
+ "status": "PROCESSED",
276
+ "processed_at": datetime.now(timezone.utc).isoformat(),
277
+ }
278
+ },
279
+ )
280
+
281
+ async def mark_outbox_failed(self, outbox_id: str, error_msg: str) -> None:
282
+ client = self._get_client()
283
+ await client.update(
284
+ index=self.outbox_index,
285
+ id=outbox_id,
286
+ body={
287
+ "script": {
288
+ "source": """
289
+ ctx._source.attempts += 1;
290
+ ctx._source.last_error = params.err;
291
+ ctx._source.status = (ctx._source.attempts >= 5) ? 'DEAD_LETTER' : 'RETRY';
292
+ """,
293
+ "lang": "painless",
294
+ "params": {"err": error_msg[:500]},
295
+ }
296
+ },
297
+ )
298
+
299
+ async def purge_user_data(self, ctx: PlatformTenantContext) -> list[str]:
300
+ client = self._get_client()
301
+ query = {
302
+ "query": {
303
+ "bool": {
304
+ "filter": [
305
+ {"term": {"tenant_id": ctx.tenant_id}},
306
+ {"term": {"user_id": ctx.user_id}},
307
+ ]
308
+ }
309
+ },
310
+ "_source": ["episode_id"],
311
+ "size": 1000,
312
+ }
313
+ resp = await client.search(index=self.episodic_index, body=query)
314
+ hits = resp.get("hits", {}).get("hits", [])
315
+ episode_ids = [h["_source"]["episode_id"] for h in hits]
316
+ if episode_ids:
317
+ await client.delete_by_query(
318
+ index=self.episodic_index,
319
+ body={"query": {"terms": {"episode_id": episode_ids}}},
320
+ refresh=True,
321
+ )
322
+ return episode_ids