Skip to content

Commit 5230cbd

Browse files
rithulkameshclaude
andcommitted
feat(memory): MemoryProvider abstraction + Snowflake backend — 2.7.0
Introduces a pluggable MemoryBackend system mirroring the LLMBackend/ LLMRouter pattern, so users can swap memory stores via config or env var. New backends: SQLite (wraps existing MemoryStore), Redis (wraps RedisMemoryStore), Snowflake (native VECTOR(FLOAT,1536) columns + VECTOR_COSINE_SIMILARITY), Vektori/pgvector (default prod backend), Platform (compat wrapper). Factory: get_memory_provider() resolves from [memory] provider field, DEVSPER_MEMORY_PROVIDER env var, or legacy backend field. Defaults to Vektori; falls back to SQLite with a warning when DATABASE_URL is unset. Snowflake credentials: resolved exclusively via the devsper credential store (devsper credentials set snowflake password) or SNOWFLAKE_* env vars — never stored in config files. Full CRUD + native vector search tested live against Snowflake Enterprise (AWS, RZDAUHB-MV83636). Also fixes two pre-existing test failures: test_pipelines used a removed internal _tools attribute (replaced with new deregister()), and test_e2e_redis_loop crashed on missing redis import before the pytest.skip() could fire. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 748ec94 commit 5230cbd

22 files changed

Lines changed: 1605 additions & 32 deletions

CHANGELOG.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.7.0] — 2026-04-07
11+
12+
### Added
13+
14+
- **MemoryProvider abstraction** — Introduced `MemoryBackend` ABC (`devsper/memory/providers/base.py`) mirroring the `LLMBackend` pattern. All memory backends implement a unified async interface: `store`, `retrieve`, `delete`, `list_memory`, `list_all_ids`, `query_similar`, `health`.
15+
- **MemoryProvider factory**`get_memory_provider()` singleton factory resolves the active backend from config (`[memory] provider`), `DEVSPER_MEMORY_PROVIDER` env var, or legacy `backend` field. Defaults to Vektori (pgvector) with automatic SQLite fallback when `DATABASE_URL` is not set.
16+
- **Snowflake memory backend** — New `SnowflakeBackend` using `VECTOR(FLOAT, 1536)` columns and `VECTOR_COSINE_SIMILARITY` for native semantic search. Credentials resolved exclusively via the devsper credential store or `SNOWFLAKE_*` env vars — never from config files.
17+
- **SQLite, Redis, Vektori, Platform backends** — Existing stores wrapped as `MemoryBackend` implementations with `get_sync_store()` escape hatch for legacy sync callers.
18+
- **Credential store: Snowflake + Redis** — Added `snowflake` and `redis_memory` providers to keyring mappings. `devsper credentials set snowflake password` stores the Snowflake password securely; `inject_into_env()` propagates all `SNOWFLAKE_*` vars automatically.
19+
- **Config schema** — Added `RedisMemoryConfig` and `SnowflakeMemoryConfig` sub-models under `[memory]`. `SnowflakeMemoryConfig` has no `password` field — credentials are credential-store-only.
20+
- **`devsper doctor` memory provider health check**`run_doctor()` now calls `provider.health()` and reports the active backend name and status.
21+
- **`devsper[snowflake]` and `devsper[redis-memory]` extras**`snowflake-connector-python>=3.6.0` and `redis>=5.0.0` optional dependency groups.
22+
- **`deregister(name)` in tool registry** — Public function to remove a single tool by name without clearing the whole registry.
23+
- **`MemoryIndex` native search delegation** — When the active backend has `supports_native_vector_search=True`, `query_memory()` and `query_across_runs()` delegate directly to `backend.query_similar()`, bypassing in-process cosine ranking.
24+
25+
### Changed
26+
27+
- **`MemoryRouter` and `MemoryIndex`** accept an optional `backend: MemoryBackend` parameter. The `_build_memory_store()` fallback now calls the factory instead of constructing `MemoryStore()` directly.
28+
- **`get_effective_memory_store()`** preserved as a backwards-compatible alias; new `get_effective_memory_backend()` returns the full `MemoryBackend`. Async-only backends (Vektori, Snowflake) are bridged via `_AsyncBridgeStore` for sync callers.
29+
- **Legacy `backend` values** (`local`, `supermemory`, `hybrid`) now map to `"vektori"` as the default production store.
30+
31+
### Fixed
32+
33+
- **`test_pipelines.py`** — Replaced `registry._tools.pop()` (broken module-level access) with new `deregister()` function.
34+
- **`test_e2e_redis_loop.py`** — Import guard prevents `ModuleNotFoundError` when `redis` is not installed; test now correctly skips instead of failing.
35+
1036
## [2.6.0] — 2026-04-06
1137

1238
### Added
@@ -82,7 +108,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
82108

83109
- **Supermemory ranking backend**`[memory] backend = "supermemory"` (default) uses the Rust `supermemory-core` ranker for hybrid lexical (+ optional embedding) memory retrieval, deduplication, and recency-aware context formatting.
84110
- **Platform memory backends**`[memory] backend` can be `platform` or `hybrid` with `platform_api_url` and `platform_org_slug`; config resolver honors `DEVSPER_PLATFORM_API_URL` and `DEVSPER_PLATFORM_ORG` when set.
85-
- **Remote platform workflows**`load_workflow("org/pkg@N")` fetches workflow specs from the Devsper Platform API when `DEVSPER_PLATFORM_API_URL`, `DEVSPER_PLATFORM_ORG`, and (when required) `DEVSPER_PLATFORM_TOKEN` are configured.
111+
- **Remote platform workflows**`load_workflow("org/pkg@N")` fetches workflow specs from the Devsper Cloud API when `DEVSPER_PLATFORM_API_URL`, `DEVSPER_PLATFORM_ORG`, and (when required) `DEVSPER_PLATFORM_TOKEN` are configured.
86112
- **Local worker pool**`devsper pool` subcommands to run the local worker pool manager (foreground spawner) alongside improved swarm execution when using pool-backed paths.
87113
- **Devsper Cloud CLI**`devsper cloud login`, `logout`, `run`, `status`, and `logs` for JWT authentication (OS keychain) and runs against the platform API.
88114

devsper/cli/init.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,31 @@ def run_doctor() -> int:
534534
except Exception as e:
535535
warnings.append(f"Memory store: {e}")
536536

537+
# Memory provider health check
538+
try:
539+
import asyncio
540+
from devsper.memory.providers.factory import get_memory_provider
541+
provider = get_memory_provider()
542+
try:
543+
loop = asyncio.get_event_loop()
544+
if loop.is_running():
545+
import concurrent.futures
546+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
547+
healthy = pool.submit(asyncio.run, provider.health()).result()
548+
else:
549+
healthy = loop.run_until_complete(provider.health())
550+
except Exception:
551+
healthy = False
552+
if healthy:
553+
ok.append(f"Memory provider: {provider.name} — healthy")
554+
else:
555+
warnings.append(
556+
f"Memory provider: {provider.name} — health check failed. "
557+
"Check credentials and connectivity."
558+
)
559+
except Exception as e:
560+
warnings.append(f"Memory provider health check failed: {e}")
561+
537562
try:
538563
from devsper.knowledge.knowledge_graph import KnowledgeGraph
539564
kg = KnowledgeGraph(store=get_default_store())

devsper/config/schema.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,46 @@ class ModelsConfig(BaseModel):
7474
quality: str | None = None # v1.6: complex tier (defaults to planner)
7575

7676

77+
class RedisMemoryConfig(BaseModel):
78+
"""Connection config for Redis memory backend."""
79+
80+
redis_url: str = "redis://localhost:6379"
81+
run_id: str = "" # empty = auto-generated from os.getpid()
82+
83+
84+
class SnowflakeMemoryConfig(BaseModel):
85+
"""Connection config for Snowflake memory backend.
86+
87+
Credentials (account, user, password, etc.) are resolved from:
88+
1. devsper credential store (devsper credentials set snowflake <key>)
89+
2. SNOWFLAKE_* environment variables
90+
3. These TOML fields as a last fallback (non-secret fields only)
91+
92+
NEVER put passwords in config files — use the credential store.
93+
"""
94+
95+
account: str = "" # or SNOWFLAKE_ACCOUNT env var
96+
user: str = "" # or SNOWFLAKE_USER env var
97+
database: str = "" # or SNOWFLAKE_DATABASE env var
98+
schema_name: str = "" # "schema" is reserved by Pydantic; or SNOWFLAKE_SCHEMA
99+
warehouse: str = "" # or SNOWFLAKE_WAREHOUSE env var
100+
role: str = "" # or SNOWFLAKE_ROLE env var
101+
table: str = "devsper_memory"
102+
103+
77104
class MemoryConfig(BaseModel):
78105
enabled: bool = True
79106
store_results: bool = True
80107
top_k: int = 5
108+
# New explicit provider field. Takes precedence over legacy `backend`.
109+
# Values: "sqlite" | "redis" | "snowflake" | "vektori"
110+
# Empty string = auto-detect (vektori if DATABASE_URL set, else sqlite).
111+
provider: str = ""
81112
backend: Literal["local", "platform", "hybrid", "supermemory"] = "supermemory"
82113
platform_api_url: str = ""
83114
platform_org_slug: str = ""
115+
redis: RedisMemoryConfig = Field(default_factory=RedisMemoryConfig)
116+
snowflake: SnowflakeMemoryConfig = Field(default_factory=SnowflakeMemoryConfig)
84117

85118

86119
class KnowledgeConfig(BaseModel):

devsper/credentials/cli.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
"gemini": ["api_key"],
2323
"azure": ["endpoint", "api_key", "deployment", "api_version"],
2424
"azure_anthropic": ["endpoint", "api_key", "deployment"],
25+
"snowflake": ["account", "user", "password", "database", "schema", "warehouse", "role"],
26+
"redis_memory": ["url"],
2527
}
2628

2729
# Keys that must never be shown in list (only "(stored)"); all others can show value
28-
SENSITIVE_KEYS = {"api_key", "token"}
30+
SENSITIVE_KEYS = {"api_key", "token", "password"}
2931

3032
# (provider, key) -> env var name for export
3133
PROVIDER_KEY_TO_ENV: dict[tuple[str, str], str] = {
@@ -40,6 +42,14 @@
4042
("azure_anthropic", "endpoint"): "AZURE_ANTHROPIC_ENDPOINT",
4143
("azure_anthropic", "api_key"): "AZURE_ANTHROPIC_API_KEY",
4244
("azure_anthropic", "deployment"): "AZURE_ANTHROPIC_DEPLOYMENT_NAME",
45+
("snowflake", "account"): "SNOWFLAKE_ACCOUNT",
46+
("snowflake", "user"): "SNOWFLAKE_USER",
47+
("snowflake", "password"): "SNOWFLAKE_PASSWORD",
48+
("snowflake", "database"): "SNOWFLAKE_DATABASE",
49+
("snowflake", "schema"): "SNOWFLAKE_SCHEMA",
50+
("snowflake", "warehouse"): "SNOWFLAKE_WAREHOUSE",
51+
("snowflake", "role"): "SNOWFLAKE_ROLE",
52+
("redis_memory", "url"): "REDIS_URL",
4353
}
4454

4555

devsper/credentials/store.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@
1919
("azure_anthropic", "api_key", "AZURE_ANTHROPIC_API_KEY"),
2020
("azure_anthropic", "endpoint", "AZURE_ANTHROPIC_ENDPOINT"),
2121
("azure_anthropic", "deployment", "AZURE_ANTHROPIC_DEPLOYMENT_NAME"),
22+
# Memory backends
23+
("snowflake", "account", "SNOWFLAKE_ACCOUNT"),
24+
("snowflake", "user", "SNOWFLAKE_USER"),
25+
("snowflake", "password", "SNOWFLAKE_PASSWORD"),
26+
("snowflake", "database", "SNOWFLAKE_DATABASE"),
27+
("snowflake", "schema", "SNOWFLAKE_SCHEMA"),
28+
("snowflake", "warehouse", "SNOWFLAKE_WAREHOUSE"),
29+
("snowflake", "role", "SNOWFLAKE_ROLE"),
30+
("redis_memory", "url", "REDIS_URL"),
2231
]
2332

2433
SERVICE_NAME = "devsper"
@@ -31,6 +40,8 @@
3140
"gemini": ["api_key"],
3241
"azure": ["endpoint", "api_key", "deployment", "api_version"],
3342
"azure_anthropic": ["endpoint", "api_key", "deployment"],
43+
"snowflake": ["account", "user", "password", "database", "schema", "warehouse", "role"],
44+
"redis_memory": ["url"],
3445
}
3546

3647

devsper/memory/context.py

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,95 @@ def detach_memory_context(
3333
_current_run_id.reset(tokens[2])
3434

3535

36+
def get_effective_memory_backend():
37+
"""
38+
Return the active MemoryBackend for the current context.
39+
Prefers the context-var store (set by attach_memory_context) if it is a MemoryBackend,
40+
otherwise returns the process-level singleton from the factory.
41+
"""
42+
s = _current_store.get()
43+
if s is not None and hasattr(s, "health"):
44+
return s # it is already a MemoryBackend
45+
from devsper.memory.providers.factory import get_memory_provider
46+
47+
return get_memory_provider()
48+
49+
3650
def get_effective_memory_store():
37-
"""Prefer agent/router store when set; else process default SQLite store."""
51+
"""
52+
Legacy: return a synchronous MemoryStore compatible object.
53+
Backends that wrap a sync store (sqlite, redis, platform) expose get_sync_store().
54+
Async-only backends (vektori, snowflake) return an _AsyncBridgeStore shim.
55+
"""
3856
s = _current_store.get()
39-
if s is not None:
57+
if s is not None and not hasattr(s, "health"):
58+
# Legacy MemoryStore object set directly via attach_memory_context
4059
return s
41-
from devsper.memory.memory_store import get_default_store
60+
backend = get_effective_memory_backend()
61+
if hasattr(backend, "get_sync_store"):
62+
return backend.get_sync_store()
63+
# Async-only backend: return a sync bridge so legacy callers still work
64+
return _AsyncBridgeStore(backend)
65+
66+
67+
class _AsyncBridgeStore:
68+
"""
69+
Sync shim for async-only backends (VektoriBackend, SnowflakeBackend).
70+
Runs coroutines via asyncio in a thread so sync callers don't need to be async.
71+
This mirrors the _run_async() pattern already used in server/memory_utils.py.
72+
"""
73+
74+
def __init__(self, backend) -> None:
75+
self._backend = backend
76+
77+
def _run(self, coro):
78+
import asyncio
79+
80+
try:
81+
loop = asyncio.get_running_loop()
82+
except RuntimeError:
83+
loop = None
84+
if loop and loop.is_running():
85+
import concurrent.futures
86+
87+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
88+
return pool.submit(asyncio.run, coro).result()
89+
return asyncio.run(coro)
90+
91+
def store(self, record, namespace=None):
92+
return self._run(self._backend.store(record, namespace))
93+
94+
def retrieve(self, memory_id, namespace=None):
95+
try:
96+
return self._run(self._backend.retrieve(memory_id, namespace))
97+
except NotImplementedError:
98+
return None
99+
100+
def delete(self, memory_id, namespace=None):
101+
try:
102+
return self._run(self._backend.delete(memory_id, namespace))
103+
except NotImplementedError:
104+
return False
105+
106+
def list_memory(
107+
self,
108+
memory_type=None,
109+
limit=100,
110+
offset=0,
111+
tag_contains=None,
112+
include_archived=False,
113+
run_id_filter=None,
114+
namespace=None,
115+
):
116+
return self._run(
117+
self._backend.list_memory(
118+
memory_type, limit, offset, tag_contains,
119+
include_archived, run_id_filter, namespace,
120+
)
121+
)
42122

43-
return get_default_store()
123+
def list_all_ids(self, memory_type=None, namespace=None):
124+
return self._run(self._backend.list_all_ids(memory_type, namespace))
44125

45126

46127
def get_effective_memory_namespace() -> str | None:

devsper/memory/memory_index.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@
22
Semantic search across stored memory via embeddings and top_k retrieval.
33
"""
44

5+
from __future__ import annotations
6+
7+
from typing import TYPE_CHECKING
8+
59
from devsper.memory.embeddings import embed_text
610
from devsper.memory.memory_store import MemoryStore
711
from devsper.memory.memory_types import MemoryRecord
812
from devsper.memory.supermemory_rust_ranker import rank_memories
913

14+
if TYPE_CHECKING:
15+
from devsper.memory.providers.base import MemoryBackend
16+
1017

1118
def _cosine_sim(a: list[float], b: list[float]) -> float:
1219
if not a or not b or len(a) != len(b):
@@ -19,14 +26,45 @@ def _cosine_sim(a: list[float], b: list[float]) -> float:
1926
return dot / (na * nb)
2027

2128

29+
def _run_async_in_thread(coro):
30+
"""Run a coroutine synchronously, safely bridging from sync context."""
31+
import asyncio
32+
import concurrent.futures
33+
34+
try:
35+
loop = asyncio.get_running_loop()
36+
except RuntimeError:
37+
loop = None
38+
if loop and loop.is_running():
39+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
40+
return pool.submit(asyncio.run, coro).result()
41+
return asyncio.run(coro)
42+
43+
2244
class MemoryIndex:
2345
"""
2446
Vector search over memory. Uses store for persistence and optional
2547
embeddings on records for query_memory(text, top_k).
48+
49+
When a MemoryBackend with supports_native_vector_search=True is provided,
50+
query_memory() and query_across_runs() delegate to the backend's native
51+
vector search (e.g. Snowflake VECTOR_COSINE_SIMILARITY, pgvector <=>),
52+
bypassing in-process cosine ranking.
2653
"""
2754

28-
def __init__(self, store: MemoryStore | None = None, ranking_backend: str = "local") -> None:
29-
self.store = store or MemoryStore()
55+
def __init__(
56+
self,
57+
store: MemoryStore | None = None,
58+
ranking_backend: str = "local",
59+
backend: "MemoryBackend | None" = None,
60+
) -> None:
61+
self._backend = backend
62+
if store is not None:
63+
self.store = store
64+
elif backend is not None and hasattr(backend, "get_sync_store"):
65+
self.store = backend.get_sync_store()
66+
else:
67+
self.store = store or MemoryStore()
3068
self.ranking_backend = ranking_backend
3169

3270
def query_memory(
@@ -39,6 +77,7 @@ def query_memory(
3977
) -> list[MemoryRecord]:
4078
"""
4179
Semantic search via ranking strategy:
80+
- native (vektori/snowflake): delegates to backend.query_similar() directly.
4281
- local (default): embed query, cosine-rank records that have embeddings.
4382
- supermemory: hybrid local ranking (lexical token overlap + optional embedding
4483
cosine similarity) using `rank_memories()`; records without embeddings can
@@ -48,6 +87,22 @@ def query_memory(
4887
Use min_similarity > 0 (e.g. 0.45) to avoid injecting barely-related memory.
4988
By default excludes archived records (consolidation).
5089
"""
90+
# Fast path: delegate to native vector search when supported
91+
if self._backend is not None and self._backend.supports_native_vector_search:
92+
try:
93+
from devsper.memory.providers.base import MemoryQuery
94+
95+
query = MemoryQuery(
96+
text=text,
97+
top_k=top_k,
98+
min_similarity=min_similarity,
99+
namespace=namespace,
100+
include_archived=include_archived,
101+
)
102+
return _run_async_in_thread(self._backend.query_similar(query))
103+
except Exception:
104+
pass # fall through to in-process ranking
105+
51106
records = self.store.list_memory(
52107
limit=500, include_archived=include_archived, namespace=namespace
53108
)
@@ -112,6 +167,22 @@ def query_across_runs(
112167
v1.8: Same as query_memory but over more records (all runs), optional run_id filter.
113168
Used by CrossRunSynthesizer. Excludes archived by default.
114169
"""
170+
# Fast path: delegate to native vector search when supported
171+
if self._backend is not None and self._backend.supports_native_vector_search:
172+
try:
173+
from devsper.memory.providers.base import MemoryQuery
174+
175+
query = MemoryQuery(
176+
text=text,
177+
top_k=top_k,
178+
min_similarity=min_similarity,
179+
namespace=namespace,
180+
include_archived=include_archived,
181+
)
182+
return _run_async_in_thread(self._backend.query_similar(query))
183+
except Exception:
184+
pass # fall through to in-process ranking
185+
115186
records = self.store.list_memory(
116187
limit=2000,
117188
include_archived=include_archived,

0 commit comments

Comments
 (0)