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.
- agentic_composite_memory/__init__.py +2 -0
- agentic_composite_memory/py.typed +0 -0
- agentic_composite_memory-0.1.0.dist-info/METADATA +167 -0
- agentic_composite_memory-0.1.0.dist-info/RECORD +31 -0
- agentic_composite_memory-0.1.0.dist-info/WHEEL +4 -0
- agentic_composite_memory-0.1.0.dist-info/entry_points.txt +2 -0
- agentic_composite_memory-0.1.0.dist-info/licenses/LICENSE +17 -0
- agentic_memory_sdk/__init__.py +163 -0
- agentic_memory_sdk/adapters/__init__.py +33 -0
- agentic_memory_sdk/adapters/in_memory.py +439 -0
- agentic_memory_sdk/adapters/neo4j_cypher.py +65 -0
- agentic_memory_sdk/adapters/neptune_cypher.py +309 -0
- agentic_memory_sdk/adapters/opensearch_seed.py +322 -0
- agentic_memory_sdk/adapters/postgres_pgvector.py +484 -0
- agentic_memory_sdk/adapters/postgres_unified.py +511 -0
- agentic_memory_sdk/adapters/redis_playbook.py +157 -0
- agentic_memory_sdk/adapters/redis_unified.py +490 -0
- agentic_memory_sdk/adk_integration.py +250 -0
- agentic_memory_sdk/auth.py +380 -0
- agentic_memory_sdk/cli.py +137 -0
- agentic_memory_sdk/governance.py +713 -0
- agentic_memory_sdk/llm_extractors.py +616 -0
- agentic_memory_sdk/models.py +443 -0
- agentic_memory_sdk/ontology_loader.py +189 -0
- agentic_memory_sdk/ports.py +159 -0
- agentic_memory_sdk/py.typed +0 -0
- agentic_memory_sdk/server/__init__.py +4 -0
- agentic_memory_sdk/server/api.py +328 -0
- agentic_memory_sdk/server/main.py +384 -0
- agentic_memory_sdk/server/mcp_server.py +198 -0
- agentic_memory_sdk/service.py +1214 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from ..auth import CloudAuthProvider, EnterpriseTlsConfig
|
|
7
|
+
from ..models import (
|
|
8
|
+
GraphEdgeMutation,
|
|
9
|
+
GraphFactReadResult,
|
|
10
|
+
GraphVertexUpsert,
|
|
11
|
+
PlatformTenantContext,
|
|
12
|
+
PlaybookRecord,
|
|
13
|
+
ReasoningTraceStep,
|
|
14
|
+
)
|
|
15
|
+
from ..ports import GraphStoreAdapter, PlaybookStoreAdapter
|
|
16
|
+
from .postgres_pgvector import PostgresPgVectorAdapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _PostgresPoolBase:
|
|
20
|
+
"""Shared asyncpg connection pool & Enterprise Cloud Auth helper for PostgreSQL adapters."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
dsn: str,
|
|
25
|
+
min_pool_size: int = 2,
|
|
26
|
+
max_pool_size: int = 20,
|
|
27
|
+
auth_provider: CloudAuthProvider | None = None,
|
|
28
|
+
tls_config: EnterpriseTlsConfig | None = None,
|
|
29
|
+
):
|
|
30
|
+
self.dsn = dsn
|
|
31
|
+
self.min_pool_size = min_pool_size
|
|
32
|
+
self.max_pool_size = max_pool_size
|
|
33
|
+
self.auth_provider = auth_provider
|
|
34
|
+
self.tls_config = tls_config
|
|
35
|
+
self._pool = None
|
|
36
|
+
|
|
37
|
+
async def _get_pool(self):
|
|
38
|
+
if self._pool is None:
|
|
39
|
+
import asyncpg
|
|
40
|
+
|
|
41
|
+
pool_kwargs: dict = {
|
|
42
|
+
"min_size": self.min_pool_size,
|
|
43
|
+
"max_size": self.max_pool_size,
|
|
44
|
+
}
|
|
45
|
+
if self.auth_provider is not None:
|
|
46
|
+
pool_kwargs["user"] = await self.auth_provider.get_username()
|
|
47
|
+
pool_kwargs["password"] = self.auth_provider.get_password
|
|
48
|
+
if self.tls_config is not None:
|
|
49
|
+
pool_kwargs["ssl"] = self.tls_config.build_ssl_context()
|
|
50
|
+
self._pool = await asyncpg.create_pool(self.dsn, **pool_kwargs)
|
|
51
|
+
return self._pool
|
|
52
|
+
|
|
53
|
+
async def close(self) -> None:
|
|
54
|
+
if self._pool is not None:
|
|
55
|
+
await self._pool.close()
|
|
56
|
+
self._pool = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PostgresPlaybookAdapter(_PostgresPoolBase, PlaybookStoreAdapter):
|
|
60
|
+
"""
|
|
61
|
+
Standalone PostgreSQL + `pgvector` implementation of `PlaybookStoreAdapter`.
|
|
62
|
+
Can be deployed on its own dedicated PostgreSQL database or alongside Neo4j / Neptune
|
|
63
|
+
when an organization prefers PostgreSQL over Redis for Tier 3 Procedural Playbooks.
|
|
64
|
+
Supports Enterprise Cloud Auth (`auth_provider`) and mTLS (`tls_config`).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
async def init_schema(self, embedding_dim: int = 1536) -> None:
|
|
68
|
+
pool = await self._get_pool()
|
|
69
|
+
async with pool.acquire() as conn:
|
|
70
|
+
await conn.execute(
|
|
71
|
+
f"""
|
|
72
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
73
|
+
CREATE TABLE IF NOT EXISTS platform_playbooks (
|
|
74
|
+
tenant_id TEXT NOT NULL,
|
|
75
|
+
playbook_id TEXT NOT NULL,
|
|
76
|
+
namespace TEXT NOT NULL,
|
|
77
|
+
app_name TEXT NOT NULL,
|
|
78
|
+
trigger_intent TEXT NOT NULL,
|
|
79
|
+
workflow_steps JSONB NOT NULL,
|
|
80
|
+
resolution_summary TEXT NOT NULL,
|
|
81
|
+
success_count INTEGER NOT NULL DEFAULT 1,
|
|
82
|
+
embedding vector({embedding_dim}),
|
|
83
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
84
|
+
PRIMARY KEY (tenant_id, playbook_id)
|
|
85
|
+
);
|
|
86
|
+
"""
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def upsert_playbook(self, playbook: PlaybookRecord) -> PlaybookRecord:
|
|
90
|
+
pool = await self._get_pool()
|
|
91
|
+
async with pool.acquire() as conn:
|
|
92
|
+
existing = await conn.fetchrow(
|
|
93
|
+
"""
|
|
94
|
+
SELECT playbook_id, success_count
|
|
95
|
+
FROM platform_playbooks
|
|
96
|
+
WHERE tenant_id = $1 AND (playbook_id = $2 OR LOWER(trigger_intent) = LOWER($3))
|
|
97
|
+
LIMIT 1
|
|
98
|
+
""",
|
|
99
|
+
playbook.tenant_id,
|
|
100
|
+
playbook.playbook_id,
|
|
101
|
+
playbook.trigger_intent,
|
|
102
|
+
)
|
|
103
|
+
if existing:
|
|
104
|
+
playbook.playbook_id = existing["playbook_id"]
|
|
105
|
+
playbook.success_count = int(existing["success_count"]) + 1
|
|
106
|
+
await conn.execute(
|
|
107
|
+
"""
|
|
108
|
+
INSERT INTO platform_playbooks
|
|
109
|
+
(tenant_id, playbook_id, namespace, app_name, trigger_intent,
|
|
110
|
+
workflow_steps, resolution_summary, success_count, embedding, updated_at)
|
|
111
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9::vector, NOW())
|
|
112
|
+
ON CONFLICT (tenant_id, playbook_id) DO UPDATE
|
|
113
|
+
SET workflow_steps = EXCLUDED.workflow_steps,
|
|
114
|
+
resolution_summary = EXCLUDED.resolution_summary,
|
|
115
|
+
success_count = EXCLUDED.success_count,
|
|
116
|
+
embedding = EXCLUDED.embedding,
|
|
117
|
+
updated_at = NOW()
|
|
118
|
+
""",
|
|
119
|
+
playbook.tenant_id,
|
|
120
|
+
playbook.playbook_id,
|
|
121
|
+
playbook.namespace,
|
|
122
|
+
playbook.app_name,
|
|
123
|
+
playbook.trigger_intent,
|
|
124
|
+
json.dumps(playbook.workflow_steps),
|
|
125
|
+
playbook.resolution_summary,
|
|
126
|
+
playbook.success_count,
|
|
127
|
+
str(playbook.embedding),
|
|
128
|
+
)
|
|
129
|
+
return playbook
|
|
130
|
+
|
|
131
|
+
async def match_playbooks(
|
|
132
|
+
self,
|
|
133
|
+
tenant_id: str,
|
|
134
|
+
read_namespaces: list[str],
|
|
135
|
+
query_embedding: list[float],
|
|
136
|
+
similarity_threshold: float = 0.85,
|
|
137
|
+
limit: int = 2,
|
|
138
|
+
) -> list[PlaybookRecord]:
|
|
139
|
+
pool = await self._get_pool()
|
|
140
|
+
async with pool.acquire() as conn:
|
|
141
|
+
rows = await conn.fetch(
|
|
142
|
+
"""
|
|
143
|
+
SELECT playbook_id, namespace, app_name, trigger_intent,
|
|
144
|
+
workflow_steps, resolution_summary, success_count,
|
|
145
|
+
1 - (embedding <=> $1::vector) AS sim
|
|
146
|
+
FROM platform_playbooks
|
|
147
|
+
WHERE tenant_id = $2
|
|
148
|
+
AND namespace = ANY($3::text[])
|
|
149
|
+
AND (1 - (embedding <=> $1::vector)) >= $4
|
|
150
|
+
ORDER BY sim DESC, success_count DESC
|
|
151
|
+
LIMIT $5
|
|
152
|
+
""",
|
|
153
|
+
str(query_embedding),
|
|
154
|
+
tenant_id,
|
|
155
|
+
read_namespaces,
|
|
156
|
+
similarity_threshold,
|
|
157
|
+
limit,
|
|
158
|
+
)
|
|
159
|
+
return [
|
|
160
|
+
PlaybookRecord(
|
|
161
|
+
playbook_id=r["playbook_id"],
|
|
162
|
+
tenant_id=tenant_id,
|
|
163
|
+
namespace=r["namespace"],
|
|
164
|
+
app_name=r["app_name"],
|
|
165
|
+
trigger_intent=r["trigger_intent"],
|
|
166
|
+
workflow_steps=json.loads(r["workflow_steps"]),
|
|
167
|
+
resolution_summary=r["resolution_summary"],
|
|
168
|
+
success_count=int(r["success_count"]),
|
|
169
|
+
)
|
|
170
|
+
for r in rows
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class PostgresGraphAdapter(_PostgresPoolBase, GraphStoreAdapter):
|
|
175
|
+
"""
|
|
176
|
+
Standalone PostgreSQL implementation of `GraphStoreAdapter` (`platform_graph_vertices`,
|
|
177
|
+
bi-temporal `platform_graph_edges` with `t_valid`/`t_invalid` functional invalidation,
|
|
178
|
+
and `platform_reasoning_traces`).
|
|
179
|
+
Can be paired with `OpenSearchSeedAdapter` or `RedisEpisodicAndSeedAdapter`, or run
|
|
180
|
+
on its own dedicated PostgreSQL instance.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
async def init_schema(self) -> None:
|
|
184
|
+
pool = await self._get_pool()
|
|
185
|
+
async with pool.acquire() as conn:
|
|
186
|
+
await conn.execute(
|
|
187
|
+
"""
|
|
188
|
+
CREATE TABLE IF NOT EXISTS platform_graph_vertices (
|
|
189
|
+
tenant_id TEXT NOT NULL,
|
|
190
|
+
vertex_id TEXT NOT NULL,
|
|
191
|
+
namespace TEXT NOT NULL,
|
|
192
|
+
entity_class TEXT NOT NULL,
|
|
193
|
+
upper_class TEXT NOT NULL,
|
|
194
|
+
canonical_name TEXT NOT NULL,
|
|
195
|
+
summary TEXT NOT NULL,
|
|
196
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
197
|
+
PRIMARY KEY (tenant_id, vertex_id)
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
CREATE TABLE IF NOT EXISTS platform_graph_edges (
|
|
201
|
+
tenant_id TEXT NOT NULL,
|
|
202
|
+
edge_id TEXT NOT NULL,
|
|
203
|
+
namespace TEXT NOT NULL,
|
|
204
|
+
source_vertex_id TEXT NOT NULL,
|
|
205
|
+
predicate TEXT NOT NULL,
|
|
206
|
+
target_vertex_id TEXT NOT NULL,
|
|
207
|
+
fact_summary TEXT NOT NULL,
|
|
208
|
+
t_valid TEXT NOT NULL,
|
|
209
|
+
t_invalid TEXT NOT NULL DEFAULT 'ACTIVE',
|
|
210
|
+
t_created TEXT NOT NULL,
|
|
211
|
+
confidence DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
|
212
|
+
reinforcement_count INTEGER NOT NULL DEFAULT 1,
|
|
213
|
+
source_episode_id TEXT NOT NULL DEFAULT '',
|
|
214
|
+
source_app TEXT NOT NULL DEFAULT '',
|
|
215
|
+
user_id TEXT NOT NULL DEFAULT '',
|
|
216
|
+
PRIMARY KEY (tenant_id, edge_id)
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
CREATE INDEX IF NOT EXISTS idx_pg_graph_edges_lookup
|
|
220
|
+
ON platform_graph_edges (tenant_id, namespace, source_vertex_id, target_vertex_id, t_invalid);
|
|
221
|
+
|
|
222
|
+
CREATE TABLE IF NOT EXISTS platform_reasoning_traces (
|
|
223
|
+
tenant_id TEXT NOT NULL,
|
|
224
|
+
run_id TEXT NOT NULL,
|
|
225
|
+
session_id TEXT NOT NULL,
|
|
226
|
+
app_name TEXT NOT NULL,
|
|
227
|
+
namespace TEXT NOT NULL,
|
|
228
|
+
steps_json JSONB NOT NULL,
|
|
229
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
230
|
+
PRIMARY KEY (tenant_id, run_id)
|
|
231
|
+
);
|
|
232
|
+
"""
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def upsert_vertices_and_bitemporal_edges(
|
|
236
|
+
self,
|
|
237
|
+
tenant_id: str,
|
|
238
|
+
vertices: Sequence[GraphVertexUpsert],
|
|
239
|
+
edges: Sequence[GraphEdgeMutation],
|
|
240
|
+
) -> None:
|
|
241
|
+
pool = await self._get_pool()
|
|
242
|
+
async with pool.acquire() as conn:
|
|
243
|
+
async with conn.transaction():
|
|
244
|
+
for v in vertices:
|
|
245
|
+
await conn.execute(
|
|
246
|
+
"""
|
|
247
|
+
INSERT INTO platform_graph_vertices
|
|
248
|
+
(tenant_id, vertex_id, namespace, entity_class, upper_class, canonical_name, summary, updated_at)
|
|
249
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
250
|
+
ON CONFLICT (tenant_id, vertex_id) DO UPDATE
|
|
251
|
+
SET summary = EXCLUDED.summary,
|
|
252
|
+
entity_class = EXCLUDED.entity_class,
|
|
253
|
+
upper_class = EXCLUDED.upper_class,
|
|
254
|
+
updated_at = NOW()
|
|
255
|
+
""",
|
|
256
|
+
tenant_id,
|
|
257
|
+
v.vertex_id,
|
|
258
|
+
v.namespace,
|
|
259
|
+
v.entity_class,
|
|
260
|
+
v.upper_class,
|
|
261
|
+
v.canonical_name,
|
|
262
|
+
v.summary,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
for e in edges:
|
|
266
|
+
if e.is_functional:
|
|
267
|
+
await conn.execute(
|
|
268
|
+
"""
|
|
269
|
+
UPDATE platform_graph_edges
|
|
270
|
+
SET t_invalid = $6
|
|
271
|
+
WHERE tenant_id = $1
|
|
272
|
+
AND source_vertex_id = $2
|
|
273
|
+
AND predicate = $3
|
|
274
|
+
AND namespace = $4
|
|
275
|
+
AND t_invalid = 'ACTIVE'
|
|
276
|
+
AND target_vertex_id <> $5
|
|
277
|
+
""",
|
|
278
|
+
tenant_id,
|
|
279
|
+
e.source_vertex_id,
|
|
280
|
+
e.predicate,
|
|
281
|
+
e.namespace,
|
|
282
|
+
e.target_vertex_id,
|
|
283
|
+
e.t_valid,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
existing = await conn.fetchrow(
|
|
287
|
+
"""
|
|
288
|
+
SELECT edge_id, reinforcement_count, confidence
|
|
289
|
+
FROM platform_graph_edges
|
|
290
|
+
WHERE tenant_id = $1
|
|
291
|
+
AND source_vertex_id = $2
|
|
292
|
+
AND predicate = $3
|
|
293
|
+
AND target_vertex_id = $4
|
|
294
|
+
AND namespace = $5
|
|
295
|
+
AND t_invalid = 'ACTIVE'
|
|
296
|
+
""",
|
|
297
|
+
tenant_id,
|
|
298
|
+
e.source_vertex_id,
|
|
299
|
+
e.predicate,
|
|
300
|
+
e.target_vertex_id,
|
|
301
|
+
e.namespace,
|
|
302
|
+
)
|
|
303
|
+
if existing:
|
|
304
|
+
await conn.execute(
|
|
305
|
+
"""
|
|
306
|
+
UPDATE platform_graph_edges
|
|
307
|
+
SET fact_summary = $3,
|
|
308
|
+
reinforcement_count = $4,
|
|
309
|
+
confidence = LEAST(1.0, $5 + 0.03)
|
|
310
|
+
WHERE tenant_id = $1 AND edge_id = $2
|
|
311
|
+
""",
|
|
312
|
+
tenant_id,
|
|
313
|
+
existing["edge_id"],
|
|
314
|
+
e.fact_summary,
|
|
315
|
+
int(existing["reinforcement_count"]) + 1,
|
|
316
|
+
float(existing["confidence"]),
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
await conn.execute(
|
|
320
|
+
"""
|
|
321
|
+
INSERT INTO platform_graph_edges
|
|
322
|
+
(tenant_id, edge_id, namespace, source_vertex_id, predicate, target_vertex_id,
|
|
323
|
+
fact_summary, t_valid, t_invalid, t_created, confidence, reinforcement_count,
|
|
324
|
+
source_episode_id, source_app, user_id)
|
|
325
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 1, $12, $13, $14)
|
|
326
|
+
ON CONFLICT (tenant_id, edge_id) DO NOTHING
|
|
327
|
+
""",
|
|
328
|
+
tenant_id,
|
|
329
|
+
e.edge_id,
|
|
330
|
+
e.namespace,
|
|
331
|
+
e.source_vertex_id,
|
|
332
|
+
e.predicate,
|
|
333
|
+
e.target_vertex_id,
|
|
334
|
+
e.fact_summary,
|
|
335
|
+
e.t_valid,
|
|
336
|
+
e.t_invalid,
|
|
337
|
+
e.t_created,
|
|
338
|
+
e.confidence,
|
|
339
|
+
e.source_episode_id,
|
|
340
|
+
e.source_app,
|
|
341
|
+
e.user_id,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
async def expand_active_subgraph(
|
|
345
|
+
self,
|
|
346
|
+
ctx: PlatformTenantContext,
|
|
347
|
+
seed_vertex_ids: Sequence[str],
|
|
348
|
+
as_of_iso: str | None = None,
|
|
349
|
+
max_hops: int = 2,
|
|
350
|
+
limit: int = 25,
|
|
351
|
+
) -> list[GraphFactReadResult]:
|
|
352
|
+
if not seed_vertex_ids:
|
|
353
|
+
return []
|
|
354
|
+
|
|
355
|
+
pool = await self._get_pool()
|
|
356
|
+
frontier = list(dict.fromkeys(seed_vertex_ids))
|
|
357
|
+
visited_vertices = set(frontier)
|
|
358
|
+
seen_edges: set[str] = set()
|
|
359
|
+
results: list[GraphFactReadResult] = []
|
|
360
|
+
|
|
361
|
+
async with pool.acquire() as conn:
|
|
362
|
+
for _hop in range(max(1, max_hops)):
|
|
363
|
+
if not frontier or len(results) >= limit:
|
|
364
|
+
break
|
|
365
|
+
|
|
366
|
+
if as_of_iso is None:
|
|
367
|
+
rows = await conn.fetch(
|
|
368
|
+
"""
|
|
369
|
+
SELECT e.edge_id, e.source_vertex_id, e.predicate, e.target_vertex_id,
|
|
370
|
+
e.fact_summary, e.t_valid, e.t_invalid, e.namespace,
|
|
371
|
+
e.source_episode_id, e.confidence, e.reinforcement_count,
|
|
372
|
+
s.canonical_name AS src_name, s.entity_class AS src_cls,
|
|
373
|
+
t.canonical_name AS tgt_name, t.entity_class AS tgt_cls
|
|
374
|
+
FROM platform_graph_edges e
|
|
375
|
+
JOIN platform_graph_vertices s
|
|
376
|
+
ON s.tenant_id = e.tenant_id AND s.vertex_id = e.source_vertex_id
|
|
377
|
+
JOIN platform_graph_vertices t
|
|
378
|
+
ON t.tenant_id = e.tenant_id AND t.vertex_id = e.target_vertex_id
|
|
379
|
+
WHERE e.tenant_id = $1
|
|
380
|
+
AND e.namespace = ANY($2::text[])
|
|
381
|
+
AND s.namespace = ANY($2::text[])
|
|
382
|
+
AND t.namespace = ANY($2::text[])
|
|
383
|
+
AND (e.source_vertex_id = ANY($3::text[]) OR e.target_vertex_id = ANY($3::text[]))
|
|
384
|
+
AND e.t_invalid = 'ACTIVE'
|
|
385
|
+
LIMIT $4
|
|
386
|
+
""",
|
|
387
|
+
ctx.tenant_id,
|
|
388
|
+
ctx.read_namespaces,
|
|
389
|
+
frontier,
|
|
390
|
+
limit,
|
|
391
|
+
)
|
|
392
|
+
else:
|
|
393
|
+
rows = await conn.fetch(
|
|
394
|
+
"""
|
|
395
|
+
SELECT e.edge_id, e.source_vertex_id, e.predicate, e.target_vertex_id,
|
|
396
|
+
e.fact_summary, e.t_valid, e.t_invalid, e.namespace,
|
|
397
|
+
e.source_episode_id, e.confidence, e.reinforcement_count,
|
|
398
|
+
s.canonical_name AS src_name, s.entity_class AS src_cls,
|
|
399
|
+
t.canonical_name AS tgt_name, t.entity_class AS tgt_cls
|
|
400
|
+
FROM platform_graph_edges e
|
|
401
|
+
JOIN platform_graph_vertices s
|
|
402
|
+
ON s.tenant_id = e.tenant_id AND s.vertex_id = e.source_vertex_id
|
|
403
|
+
JOIN platform_graph_vertices t
|
|
404
|
+
ON t.tenant_id = e.tenant_id AND t.vertex_id = e.target_vertex_id
|
|
405
|
+
WHERE e.tenant_id = $1
|
|
406
|
+
AND e.namespace = ANY($2::text[])
|
|
407
|
+
AND s.namespace = ANY($2::text[])
|
|
408
|
+
AND t.namespace = ANY($2::text[])
|
|
409
|
+
AND (e.source_vertex_id = ANY($3::text[]) OR e.target_vertex_id = ANY($3::text[]))
|
|
410
|
+
AND e.t_valid <= $4
|
|
411
|
+
AND (e.t_invalid = 'ACTIVE' OR e.t_invalid > $4)
|
|
412
|
+
LIMIT $5
|
|
413
|
+
""",
|
|
414
|
+
ctx.tenant_id,
|
|
415
|
+
ctx.read_namespaces,
|
|
416
|
+
frontier,
|
|
417
|
+
as_of_iso,
|
|
418
|
+
limit,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
next_frontier: list[str] = []
|
|
422
|
+
for r in rows:
|
|
423
|
+
if r["edge_id"] in seen_edges:
|
|
424
|
+
continue
|
|
425
|
+
seen_edges.add(r["edge_id"])
|
|
426
|
+
results.append(
|
|
427
|
+
GraphFactReadResult(
|
|
428
|
+
source_name=r["src_name"],
|
|
429
|
+
source_class=r["src_cls"],
|
|
430
|
+
predicate=r["predicate"],
|
|
431
|
+
target_name=r["tgt_name"],
|
|
432
|
+
target_class=r["tgt_cls"],
|
|
433
|
+
fact_summary=r["fact_summary"],
|
|
434
|
+
t_valid=r["t_valid"],
|
|
435
|
+
t_invalid=r["t_invalid"],
|
|
436
|
+
namespace=r["namespace"],
|
|
437
|
+
source_episode_id=r["source_episode_id"],
|
|
438
|
+
confidence=float(r["confidence"]),
|
|
439
|
+
reinforcement_count=int(r["reinforcement_count"]),
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
for vid in (r["source_vertex_id"], r["target_vertex_id"]):
|
|
443
|
+
if vid not in visited_vertices:
|
|
444
|
+
visited_vertices.add(vid)
|
|
445
|
+
next_frontier.append(vid)
|
|
446
|
+
if len(results) >= limit:
|
|
447
|
+
break
|
|
448
|
+
frontier = next_frontier
|
|
449
|
+
|
|
450
|
+
return results[:limit]
|
|
451
|
+
|
|
452
|
+
async def record_reasoning_trace(
|
|
453
|
+
self,
|
|
454
|
+
ctx: PlatformTenantContext,
|
|
455
|
+
run_id: str,
|
|
456
|
+
session_id: str,
|
|
457
|
+
steps: Sequence[ReasoningTraceStep],
|
|
458
|
+
) -> None:
|
|
459
|
+
pool = await self._get_pool()
|
|
460
|
+
async with pool.acquire() as conn:
|
|
461
|
+
await conn.execute(
|
|
462
|
+
"""
|
|
463
|
+
INSERT INTO platform_reasoning_traces
|
|
464
|
+
(tenant_id, run_id, session_id, app_name, namespace, steps_json)
|
|
465
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
|
466
|
+
ON CONFLICT (tenant_id, run_id) DO UPDATE
|
|
467
|
+
SET steps_json = EXCLUDED.steps_json
|
|
468
|
+
""",
|
|
469
|
+
ctx.tenant_id,
|
|
470
|
+
run_id,
|
|
471
|
+
session_id,
|
|
472
|
+
ctx.app_name,
|
|
473
|
+
ctx.app_private_namespace,
|
|
474
|
+
json.dumps([s.model_dump() for s in steps]),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
async def purge_episode_lineage(
|
|
478
|
+
self, tenant_id: str, episode_ids: Sequence[str], user_id: str
|
|
479
|
+
) -> None:
|
|
480
|
+
pool = await self._get_pool()
|
|
481
|
+
async with pool.acquire() as conn:
|
|
482
|
+
await conn.execute(
|
|
483
|
+
"""
|
|
484
|
+
DELETE FROM platform_graph_edges
|
|
485
|
+
WHERE tenant_id = $1 AND (source_episode_id = ANY($2::text[]) OR user_id = $3)
|
|
486
|
+
""",
|
|
487
|
+
tenant_id,
|
|
488
|
+
list(episode_ids),
|
|
489
|
+
user_id,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class PostgresUnifiedAdapter(
|
|
494
|
+
PostgresPgVectorAdapter, PostgresGraphAdapter, PostgresPlaybookAdapter
|
|
495
|
+
):
|
|
496
|
+
"""
|
|
497
|
+
Single-Stack Pure PostgreSQL Unified Adapter (`RegistryStoreAdapter` +
|
|
498
|
+
`EpisodicAndSeedStoreAdapter` + `GraphStoreAdapter` + `PlaybookStoreAdapter`).
|
|
499
|
+
|
|
500
|
+
Combines `PostgresPgVectorAdapter`, `PostgresGraphAdapter`, and `PostgresPlaybookAdapter`
|
|
501
|
+
over a single shared connection pool.
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
async def init_schema(
|
|
505
|
+
self, embedding_dim: int = 1536, default_tenant: str = "t1"
|
|
506
|
+
) -> None:
|
|
507
|
+
await PostgresPgVectorAdapter.init_schema(
|
|
508
|
+
self, embedding_dim=embedding_dim, default_tenant=default_tenant
|
|
509
|
+
)
|
|
510
|
+
await PostgresGraphAdapter.init_schema(self)
|
|
511
|
+
await PostgresPlaybookAdapter.init_schema(self, embedding_dim=embedding_dim)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..auth import CloudAuthProvider, EnterpriseTlsConfig
|
|
7
|
+
from ..models import PlaybookRecord
|
|
8
|
+
from ..ports import PlaybookStoreAdapter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RedisPlaybookAndWorkingAdapter(PlaybookStoreAdapter):
|
|
12
|
+
"""
|
|
13
|
+
Redis Iris-inspired Semantic Playbook Cache & Working Memory TTL Buffer Adapter.
|
|
14
|
+
- When `redis_url` is provided (and `redis.asyncio` is installed), persists playbooks
|
|
15
|
+
and working buffers in Redis hashes/keys with configurable TTLs for sub-5ms lookup.
|
|
16
|
+
- Supports Enterprise Cloud Auth (`auth_provider`: AWS ElastiCache/MemoryDB IAM,
|
|
17
|
+
Azure Managed Redis Entra ID, GCP Memorystore IAM, or Vault secrets) and mTLS (`tls_config`).
|
|
18
|
+
- Includes a built-in zero-dependency in-memory store fallback so the exact same
|
|
19
|
+
Semantic Playbook caching API works in local/unit tests without a Redis server.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
redis_url: str | None = None,
|
|
25
|
+
key_prefix: str = "adk_memory:playbook",
|
|
26
|
+
default_ttl_sec: int = 86400 * 30,
|
|
27
|
+
auth_provider: CloudAuthProvider | None = None,
|
|
28
|
+
tls_config: EnterpriseTlsConfig | None = None,
|
|
29
|
+
):
|
|
30
|
+
self.redis_url = redis_url
|
|
31
|
+
self.key_prefix = key_prefix
|
|
32
|
+
self.default_ttl_sec = default_ttl_sec
|
|
33
|
+
self.auth_provider = auth_provider
|
|
34
|
+
self.tls_config = tls_config
|
|
35
|
+
self._redis: Any = None
|
|
36
|
+
self._local_playbooks: dict[tuple[str, str], PlaybookRecord] = {}
|
|
37
|
+
|
|
38
|
+
async def _get_redis(self):
|
|
39
|
+
if not self.redis_url:
|
|
40
|
+
return None
|
|
41
|
+
if self._redis is None:
|
|
42
|
+
import redis.asyncio as aioredis # type: ignore
|
|
43
|
+
|
|
44
|
+
conn_kwargs: dict[str, Any] = {"decode_responses": True}
|
|
45
|
+
if self.auth_provider is not None:
|
|
46
|
+
user, pwd = await self.auth_provider.get_credentials()
|
|
47
|
+
if user and user != "default":
|
|
48
|
+
conn_kwargs["username"] = user
|
|
49
|
+
conn_kwargs["password"] = pwd
|
|
50
|
+
if self.tls_config is not None and self.tls_config.enabled:
|
|
51
|
+
conn_kwargs["ssl"] = True
|
|
52
|
+
if self.tls_config.ca_file:
|
|
53
|
+
conn_kwargs["ssl_ca_certs"] = self.tls_config.ca_file
|
|
54
|
+
if self.tls_config.client_cert_file:
|
|
55
|
+
conn_kwargs["ssl_certfile"] = self.tls_config.client_cert_file
|
|
56
|
+
if self.tls_config.client_key_file:
|
|
57
|
+
conn_kwargs["ssl_keyfile"] = self.tls_config.client_key_file
|
|
58
|
+
|
|
59
|
+
self._redis = aioredis.from_url(self.redis_url, **conn_kwargs)
|
|
60
|
+
return self._redis
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
64
|
+
if not a or not b or len(a) != len(b):
|
|
65
|
+
return 0.0
|
|
66
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
67
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
68
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
69
|
+
return dot / (na * nb) if (na and nb) else 0.0
|
|
70
|
+
|
|
71
|
+
async def upsert_playbook(self, playbook: PlaybookRecord) -> PlaybookRecord:
|
|
72
|
+
"""
|
|
73
|
+
Caches or reinforces a proven Agent Playbook (`trigger_intent -> workflow_steps -> resolution_summary`).
|
|
74
|
+
"""
|
|
75
|
+
r = await self._get_redis()
|
|
76
|
+
if r is not None:
|
|
77
|
+
rkey = f"{self.key_prefix}:{playbook.tenant_id}"
|
|
78
|
+
existing_raw = await r.hget(rkey, playbook.playbook_id)
|
|
79
|
+
if existing_raw:
|
|
80
|
+
existing = PlaybookRecord.model_validate_json(existing_raw)
|
|
81
|
+
playbook.success_count = existing.success_count + 1
|
|
82
|
+
else:
|
|
83
|
+
all_raw = await r.hgetall(rkey)
|
|
84
|
+
for pb_id, raw_json in all_raw.items():
|
|
85
|
+
cand = PlaybookRecord.model_validate_json(raw_json)
|
|
86
|
+
if (
|
|
87
|
+
cand.namespace == playbook.namespace
|
|
88
|
+
and cand.trigger_intent.strip().lower()
|
|
89
|
+
== playbook.trigger_intent.strip().lower()
|
|
90
|
+
):
|
|
91
|
+
playbook.playbook_id = pb_id
|
|
92
|
+
playbook.success_count = cand.success_count + 1
|
|
93
|
+
break
|
|
94
|
+
await r.hset(rkey, playbook.playbook_id, playbook.model_dump_json())
|
|
95
|
+
await r.expire(rkey, self.default_ttl_sec)
|
|
96
|
+
return playbook
|
|
97
|
+
|
|
98
|
+
# In-memory fallback with identical semantics
|
|
99
|
+
for (t_id, pb_id), existing in list(self._local_playbooks.items()):
|
|
100
|
+
if (
|
|
101
|
+
t_id == playbook.tenant_id
|
|
102
|
+
and existing.namespace == playbook.namespace
|
|
103
|
+
and existing.trigger_intent.strip().lower()
|
|
104
|
+
== playbook.trigger_intent.strip().lower()
|
|
105
|
+
):
|
|
106
|
+
playbook.playbook_id = pb_id
|
|
107
|
+
playbook.success_count = existing.success_count + 1
|
|
108
|
+
self._local_playbooks[(t_id, pb_id)] = playbook
|
|
109
|
+
return playbook
|
|
110
|
+
|
|
111
|
+
self._local_playbooks[(playbook.tenant_id, playbook.playbook_id)] = playbook
|
|
112
|
+
return playbook
|
|
113
|
+
|
|
114
|
+
async def match_playbooks(
|
|
115
|
+
self,
|
|
116
|
+
tenant_id: str,
|
|
117
|
+
read_namespaces: list[str],
|
|
118
|
+
query_embedding: list[float],
|
|
119
|
+
similarity_threshold: float = 0.85,
|
|
120
|
+
limit: int = 2,
|
|
121
|
+
) -> list[PlaybookRecord]:
|
|
122
|
+
"""
|
|
123
|
+
Sub-5ms semantic lookup for proven Agent Playbooks matching the user's intent vector.
|
|
124
|
+
Strictly isolated by `tenant_id` and `read_namespaces`.
|
|
125
|
+
"""
|
|
126
|
+
candidates: list[PlaybookRecord] = []
|
|
127
|
+
r = await self._get_redis()
|
|
128
|
+
if r is not None:
|
|
129
|
+
rkey = f"{self.key_prefix}:{tenant_id}"
|
|
130
|
+
raw_map = await r.hgetall(rkey)
|
|
131
|
+
for raw_json in raw_map.values():
|
|
132
|
+
pb = PlaybookRecord.model_validate_json(raw_json)
|
|
133
|
+
if pb.namespace in read_namespaces:
|
|
134
|
+
candidates.append(pb)
|
|
135
|
+
else:
|
|
136
|
+
candidates = [
|
|
137
|
+
pb
|
|
138
|
+
for (t_id, _), pb in self._local_playbooks.items()
|
|
139
|
+
if t_id == tenant_id and pb.namespace in read_namespaces
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
scored: list[tuple[float, PlaybookRecord]] = []
|
|
143
|
+
for pb in candidates:
|
|
144
|
+
sim = self._cosine(pb.embedding, query_embedding)
|
|
145
|
+
if sim >= similarity_threshold:
|
|
146
|
+
scored.append((sim, pb))
|
|
147
|
+
|
|
148
|
+
scored.sort(key=lambda item: (item[0], item[1].success_count), reverse=True)
|
|
149
|
+
return [pb for _, pb in scored[:limit]]
|
|
150
|
+
|
|
151
|
+
async def close(self) -> None:
|
|
152
|
+
if self._redis is not None:
|
|
153
|
+
if hasattr(self._redis, "aclose"):
|
|
154
|
+
await self._redis.aclose()
|
|
155
|
+
else:
|
|
156
|
+
await self._redis.close()
|
|
157
|
+
self._redis = None
|