Skip to content

Commit 811b7b2

Browse files
committed
chore: update memory management with namespace support
- Bump version to 2.1.7. - Introduce memory namespace handling across memory store, router, and tools for better organization. - Update Redis memory store to support namespaces. - Modify agent and worker nodes to utilize project-specific memory namespaces. - Enhance memory tools to respect namespaces during memory operations.
1 parent 2f4adb2 commit 811b7b2

19 files changed

Lines changed: 401 additions & 79 deletions

devsper/agents/agent.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,12 +379,14 @@ def __init__(
379379
message_bus=None,
380380
audit_logger=None,
381381
audit_run_id: str = "",
382+
memory_namespace: str | None = None,
382383
):
383384
self.model_name = model_name
384385
self.event_log = event_log or EventLog()
385386
self.use_tools = use_tools
386387
self.max_tool_iterations = max_tool_iterations
387388
self.memory_router = memory_router
389+
self.memory_namespace = memory_namespace
388390
self.store_result_to_memory = store_result_to_memory
389391
self.reasoning_store = reasoning_store
390392
self.user_task = user_task
@@ -397,8 +399,14 @@ def __init__(
397399
def run(self, request: AgentRequest) -> AgentResponse:
398400
"""Stateless run: all context in AgentRequest, all output in AgentResponse."""
399401
import time
402+
from devsper.memory.context import attach_memory_context, detach_memory_context
403+
400404
t0 = time.perf_counter()
401405
task_id = request.task.id
406+
ctx = attach_memory_context(
407+
getattr(self.memory_router, "store", None) if self.memory_router else None,
408+
self.memory_namespace,
409+
)
402410
try:
403411
self._emit(events.AGENT_STARTED, {"task_id": task_id})
404412
self._emit(events.TASK_STARTED, {"task_id": task_id})
@@ -503,6 +511,8 @@ def run(self, request: AgentRequest) -> AgentResponse:
503511
error=str(e),
504512
success=False,
505513
)
514+
finally:
515+
detach_memory_context(ctx)
506516

507517
def build_request(
508518
self,
@@ -690,7 +700,7 @@ def _store_result_to_memory(self, task: Task, text: str) -> None:
690700
index = getattr(self.memory_router, "index", None)
691701
if isinstance(index, MemoryIndex):
692702
record = index.ensure_embedding(record)
693-
store.store(record)
703+
store.store(record, namespace=self.memory_namespace)
694704

695705
def _run_with_tools_for_request(
696706
self,

devsper/memory/context.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Process/async context for memory store + namespace (tools + agent execution)."""
2+
3+
from __future__ import annotations
4+
5+
from contextvars import ContextVar, Token
6+
from typing import TYPE_CHECKING
7+
8+
if TYPE_CHECKING:
9+
pass
10+
11+
_current_store: ContextVar[object | None] = ContextVar("devsper_memory_store", default=None)
12+
_current_namespace: ContextVar[str | None] = ContextVar("devsper_memory_namespace", default=None)
13+
14+
15+
def attach_memory_context(store: object | None, namespace: str | None) -> tuple[Token[object | None], Token[str | None]]:
16+
return (_current_store.set(store), _current_namespace.set(namespace))
17+
18+
19+
def detach_memory_context(tokens: tuple[Token[object | None], Token[str | None]]) -> None:
20+
_current_store.reset(tokens[0])
21+
_current_namespace.reset(tokens[1])
22+
23+
24+
def get_effective_memory_store():
25+
"""Prefer agent/router store when set; else process default SQLite store."""
26+
s = _current_store.get()
27+
if s is not None:
28+
return s
29+
from devsper.memory.memory_store import get_default_store
30+
31+
return get_default_store()
32+
33+
34+
def get_effective_memory_namespace() -> str | None:
35+
"""Active namespace for tool memory ops (None = legacy global/run default)."""
36+
return _current_namespace.get()

devsper/memory/memory_index.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def query_memory(
3333
top_k: int = 5,
3434
min_similarity: float = 0.0,
3535
include_archived: bool = False,
36+
namespace: str | None = None,
3637
) -> list[MemoryRecord]:
3738
"""
3839
Semantic search: embed query, score against stored records with embeddings,
@@ -41,7 +42,9 @@ def query_memory(
4142
Use min_similarity > 0 (e.g. 0.45) to avoid injecting barely-related memory.
4243
By default excludes archived records (consolidation).
4344
"""
44-
records = self.store.list_memory(limit=500, include_archived=include_archived)
45+
records = self.store.list_memory(
46+
limit=500, include_archived=include_archived, namespace=namespace
47+
)
4548
if not records:
4649
return []
4750
query_emb = embed_text(text)
@@ -64,6 +67,7 @@ def query_across_runs(
6467
min_similarity: float = 0.0,
6568
run_id_filter: str | None = None,
6669
include_archived: bool = False,
70+
namespace: str | None = None,
6771
) -> list[MemoryRecord]:
6872
"""
6973
v1.8: Same as query_memory but over more records (all runs), optional run_id filter.
@@ -73,6 +77,7 @@ def query_across_runs(
7377
limit=2000,
7478
include_archived=include_archived,
7579
run_id_filter=run_id_filter,
80+
namespace=namespace,
7681
)
7782
if not records:
7883
return []

devsper/memory/memory_router.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ def __init__(
2020
index: MemoryIndex | None = None,
2121
top_k: int = 10,
2222
min_similarity: float = 0.55,
23+
default_namespace: str | None = None,
2324
) -> None:
2425
self.store = store or MemoryStore()
2526
self.index = index or MemoryIndex(self.store)
2627
self.top_k = top_k
2728
self.min_similarity = min_similarity
29+
self.default_namespace = default_namespace
2830

2931
def get_relevant_memory(self, task: str) -> list[MemoryRecord]:
3032
"""
@@ -35,6 +37,7 @@ def get_relevant_memory(self, task: str) -> list[MemoryRecord]:
3537
task,
3638
top_k=self.top_k,
3739
min_similarity=self.min_similarity,
40+
namespace=self.default_namespace,
3841
)
3942

4043
def get_memory_context(self, task: str) -> str:
@@ -44,7 +47,9 @@ def get_memory_context(self, task: str) -> str:
4447
Empty if no memories meet the relevance threshold.
4548
"""
4649
lines = []
47-
inject_records = self.store.list_memory(tag_contains="user_injection", limit=10)
50+
inject_records = self.store.list_memory(
51+
tag_contains="user_injection", limit=10, namespace=self.default_namespace
52+
)
4853
if inject_records:
4954
lines.append("USER INJECTIONS (high priority):")
5055
for r in inject_records:

devsper/memory/memory_store.py

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ def _default_db_path() -> str:
2020
return os.path.join(base, "memory.db")
2121

2222

23+
def _norm_namespace(namespace: str | None) -> str:
24+
"""SQLite partition key; empty string means legacy default (same as namespace=None)."""
25+
if namespace is None or namespace == "":
26+
return ""
27+
return namespace
28+
29+
2330
class MemoryStore:
2431
"""Local persistent store for memory records. Uses SQLite."""
2532

@@ -60,8 +67,17 @@ def _init_schema(self) -> None:
6067
conn.execute("ALTER TABLE memory ADD COLUMN archived INTEGER DEFAULT 0")
6168
except sqlite3.OperationalError:
6269
pass
70+
try:
71+
conn.execute(
72+
"ALTER TABLE memory ADD COLUMN namespace TEXT NOT NULL DEFAULT ''"
73+
)
74+
except sqlite3.OperationalError:
75+
pass
76+
conn.execute(
77+
"CREATE INDEX IF NOT EXISTS ix_memory_namespace ON memory(namespace)"
78+
)
6379

64-
def store(self, record: MemoryRecord) -> str:
80+
def store(self, record: MemoryRecord, namespace: str | None = None) -> str:
6581
"""Store a memory record. Returns record id. Redacts PII if compliance.pii_redaction enabled."""
6682
content = record.content
6783
try:
@@ -84,12 +100,13 @@ def store(self, record: MemoryRecord) -> str:
84100
embedding_json = json.dumps(emb) if emb is not None else None
85101
archived = row.get("archived", 0)
86102
run_id = row.get("run_id", "") or ""
103+
ns = _norm_namespace(namespace)
87104
with self._conn() as conn:
88105
conn.execute(
89106
"""
90107
INSERT OR REPLACE INTO memory
91-
(memory_id, memory_type, content, tags, timestamp, source_task, embedding, run_id, archived)
92-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
108+
(memory_id, memory_type, content, tags, timestamp, source_task, embedding, run_id, archived, namespace)
109+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93110
""",
94111
(
95112
row["memory_id"],
@@ -101,27 +118,39 @@ def store(self, record: MemoryRecord) -> str:
101118
embedding_json,
102119
run_id,
103120
archived,
121+
ns,
104122
),
105123
)
106124
return row["memory_id"]
107125

108-
def retrieve(self, memory_id: str) -> MemoryRecord | None:
126+
def retrieve(self, memory_id: str, namespace: str | None = None) -> MemoryRecord | None:
109127
"""Retrieve a single record by id."""
128+
ns = _norm_namespace(namespace)
110129
with self._conn() as conn:
111130
conn.row_factory = sqlite3.Row
112131
cur = conn.execute(
113-
"SELECT memory_id, memory_type, content, tags, timestamp, source_task, embedding, run_id, archived FROM memory WHERE memory_id = ?",
114-
(memory_id,),
132+
"""
133+
SELECT memory_id, memory_type, content, tags, timestamp, source_task, embedding, run_id, archived,
134+
COALESCE(namespace, '') AS namespace
135+
FROM memory WHERE memory_id = ? AND COALESCE(namespace, '') = ?
136+
""",
137+
(memory_id, ns),
115138
)
116139
row = cur.fetchone()
117140
if row is None:
118141
return None
119-
return _row_to_record(dict(row))
142+
d = dict(row)
143+
d.pop("namespace", None)
144+
return _row_to_record(d)
120145

121-
def delete(self, memory_id: str) -> bool:
146+
def delete(self, memory_id: str, namespace: str | None = None) -> bool:
122147
"""Delete a record. Returns True if something was deleted."""
148+
ns = _norm_namespace(namespace)
123149
with self._conn() as conn:
124-
cur = conn.execute("DELETE FROM memory WHERE memory_id = ?", (memory_id,))
150+
cur = conn.execute(
151+
"DELETE FROM memory WHERE memory_id = ? AND COALESCE(namespace, '') = ?",
152+
(memory_id, ns),
153+
)
125154
return cur.rowcount > 0
126155

127156
def list_memory(
@@ -132,12 +161,14 @@ def list_memory(
132161
tag_contains: str | None = None,
133162
include_archived: bool = False,
134163
run_id_filter: str | None = None,
164+
namespace: str | None = None,
135165
) -> list[MemoryRecord]:
136-
"""List records, optionally filtered by type, tag, archived, run_id, with limit/offset."""
166+
"""List records, optionally filtered by type, tag, archived, run_id, namespace, with limit/offset."""
167+
ns = _norm_namespace(namespace)
137168
with self._conn() as conn:
138169
conn.row_factory = sqlite3.Row
139-
conditions = []
140-
params = []
170+
conditions = ["COALESCE(namespace, '') = ?"]
171+
params: list = [ns]
141172
if memory_type is not None:
142173
conditions.append("memory_type = ?")
143174
params.append(memory_type.value)
@@ -149,40 +180,64 @@ def list_memory(
149180
if run_id_filter is not None:
150181
conditions.append("run_id = ?")
151182
params.append(run_id_filter)
152-
where = (" WHERE " + " AND ".join(conditions)) if conditions else ""
183+
where = " WHERE " + " AND ".join(conditions)
153184
params.extend([limit, offset])
154185
cur = conn.execute(
155186
f"""
156187
SELECT memory_id, memory_type, content, tags, timestamp, source_task, embedding,
157-
COALESCE(run_id, '') as run_id, COALESCE(archived, 0) as archived
188+
COALESCE(run_id, '') as run_id, COALESCE(archived, 0) as archived,
189+
COALESCE(namespace, '') as namespace
158190
FROM memory{where} ORDER BY timestamp DESC LIMIT ? OFFSET ?
159191
""",
160192
params,
161193
)
162194
rows = cur.fetchall()
163-
return [_row_to_record(dict(r)) for r in rows]
195+
out = []
196+
for r in rows:
197+
d = dict(r)
198+
d.pop("namespace", None)
199+
out.append(_row_to_record(d))
200+
return out
164201

165-
def list_all_ids(self, memory_type: MemoryType | None = None) -> list[str]:
202+
def list_all_ids(self, memory_type: MemoryType | None = None, namespace: str | None = None) -> list[str]:
166203
"""List all memory ids (for index sync)."""
204+
ns = _norm_namespace(namespace)
167205
with self._conn() as conn:
168206
if memory_type is not None:
169207
cur = conn.execute(
170-
"SELECT memory_id FROM memory WHERE memory_type = ?",
171-
(memory_type.value,),
208+
"""
209+
SELECT memory_id FROM memory
210+
WHERE memory_type = ? AND COALESCE(namespace, '') = ?
211+
""",
212+
(memory_type.value, ns),
172213
)
173214
else:
174-
cur = conn.execute("SELECT memory_id FROM memory")
215+
cur = conn.execute(
216+
"SELECT memory_id FROM memory WHERE COALESCE(namespace, '') = ?",
217+
(ns,),
218+
)
175219
return [r[0] for r in cur.fetchall()]
176220

177-
def set_archived(self, memory_id: str, archived: bool = True) -> bool:
221+
def set_archived(self, memory_id: str, archived: bool = True, namespace: str | None = None) -> bool:
178222
"""v1.8: Mark a record as archived (e.g. after consolidation)."""
223+
ns = _norm_namespace(namespace)
179224
with self._conn() as conn:
180225
cur = conn.execute(
181-
"UPDATE memory SET archived = ? WHERE memory_id = ?",
182-
(1 if archived else 0, memory_id),
226+
"""
227+
UPDATE memory SET archived = ?
228+
WHERE memory_id = ? AND COALESCE(namespace, '') = ?
229+
""",
230+
(1 if archived else 0, memory_id, ns),
183231
)
184232
return cur.rowcount > 0
185233

234+
def purge_namespace(self, namespace: str) -> None:
235+
"""Remove all memory rows for a logical namespace (e.g. when a project is deleted)."""
236+
if not namespace or not str(namespace).strip():
237+
return
238+
with self._conn() as conn:
239+
conn.execute("DELETE FROM memory WHERE namespace = ?", (namespace,))
240+
186241

187242
def _row_to_record(row: dict) -> MemoryRecord:
188243
tags_str = row.get("tags") or ""

0 commit comments

Comments
 (0)