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,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