Compare commits
1 Commits
fix/457-ss
...
whip/322-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d371033bf |
264
agent/memory/__init__.py
Normal file
264
agent/memory/__init__.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""Memory Backend Interface — pluggable cross-session user modeling.
|
||||
|
||||
Provides a common interface for memory backends that persist user
|
||||
preferences and patterns across sessions. Three implementations:
|
||||
|
||||
1. NullBackend (default): Zero overhead when disabled
|
||||
2. LocalBackend: SQLite-based, zero cloud dependency
|
||||
3. HonchoBackend (opt-in): Honcho AI-native memory, requires API key
|
||||
|
||||
All backends return empty results when disabled — no runtime overhead.
|
||||
|
||||
Usage:
|
||||
from agent.memory import get_memory_backend
|
||||
|
||||
backend = get_memory_backend()
|
||||
backend.store_interaction("user", "prefers_python", session_messages)
|
||||
context = backend.get_user_context("user", "What tools does this user prefer?")
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
"""A single memory entry."""
|
||||
key: str
|
||||
value: str
|
||||
user_id: str
|
||||
created_at: float = 0
|
||||
updated_at: float = 0
|
||||
entry_type: str = "preference" # preference, pattern, fact, context
|
||||
confidence: float = 1.0
|
||||
source_session: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
now = time.time()
|
||||
if not self.created_at:
|
||||
self.created_at = now
|
||||
if not self.updated_at:
|
||||
self.updated_at = now
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserContext:
|
||||
"""Aggregated user context from a memory backend."""
|
||||
user_id: str
|
||||
preferences: Dict[str, str] = field(default_factory=dict)
|
||||
patterns: List[str] = field(default_factory=list)
|
||||
recent_topics: List[str] = field(default_factory=list)
|
||||
summary: str = ""
|
||||
backend_name: str = ""
|
||||
query_time_ms: float = 0
|
||||
|
||||
|
||||
class MemoryBackend(ABC):
|
||||
"""Abstract interface for memory backends."""
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check if this backend is configured and usable."""
|
||||
|
||||
@abstractmethod
|
||||
def store(self, user_id: str, key: str, value: str, metadata: Dict = None) -> bool:
|
||||
"""Store a memory entry."""
|
||||
|
||||
@abstractmethod
|
||||
def retrieve(self, user_id: str, key: str) -> Optional[MemoryEntry]:
|
||||
"""Retrieve a single memory entry."""
|
||||
|
||||
@abstractmethod
|
||||
def query(self, user_id: str, query_text: str, limit: int = 10) -> List[MemoryEntry]:
|
||||
"""Query memories relevant to a text query."""
|
||||
|
||||
@abstractmethod
|
||||
def list_entries(self, user_id: str) -> List[MemoryEntry]:
|
||||
"""List all entries for a user."""
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, user_id: str, key: str) -> bool:
|
||||
"""Delete a memory entry."""
|
||||
|
||||
def store_interaction(
|
||||
self,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Store insights from a session interaction.
|
||||
|
||||
Default implementation extracts tool names and stores as patterns.
|
||||
Subclasses can override for richer extraction.
|
||||
"""
|
||||
tool_names = []
|
||||
for msg in messages:
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
raw = msg.get("tool_calls")
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
calls = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if isinstance(calls, list):
|
||||
for tc in calls:
|
||||
if isinstance(tc, dict):
|
||||
func = tc.get("function", {})
|
||||
name = func.get("name", "")
|
||||
if name and name not in ("clarify", "memory", "fact_store"):
|
||||
tool_names.append(name)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
if tool_names:
|
||||
pattern_key = f"session_{session_id}_tools"
|
||||
self.store(
|
||||
user_id,
|
||||
pattern_key,
|
||||
json.dumps(tool_names),
|
||||
{"type": "pattern", "session_id": session_id},
|
||||
)
|
||||
return True
|
||||
|
||||
def get_user_context(self, user_id: str, query: str = "") -> UserContext:
|
||||
"""Get aggregated user context for a query.
|
||||
|
||||
Default implementation queries and aggregates. Subclasses can
|
||||
override for richer context (e.g., Honcho's dialectic queries).
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
|
||||
entries = self.query(user_id, query or "", limit=20)
|
||||
|
||||
preferences = {}
|
||||
patterns = []
|
||||
recent = []
|
||||
|
||||
for entry in entries:
|
||||
if entry.entry_type == "preference":
|
||||
preferences[entry.key] = entry.value
|
||||
elif entry.entry_type == "pattern":
|
||||
try:
|
||||
tools = json.loads(entry.value)
|
||||
if isinstance(tools, list):
|
||||
patterns.extend(tools)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
patterns.append(entry.value)
|
||||
else:
|
||||
recent.append(entry.key)
|
||||
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
return UserContext(
|
||||
user_id=user_id,
|
||||
preferences=preferences,
|
||||
patterns=list(dict.fromkeys(patterns)), # dedupe preserving order
|
||||
recent_topics=recent[:10],
|
||||
backend_name=self.backend_name,
|
||||
query_time_ms=elapsed,
|
||||
)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def backend_name(self) -> str:
|
||||
"""Human-readable backend name."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_cloud(self) -> bool:
|
||||
"""Whether this backend requires cloud connectivity."""
|
||||
|
||||
|
||||
class NullBackend(MemoryBackend):
|
||||
"""No-op backend when memory is disabled. Zero overhead."""
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def store(self, user_id: str, key: str, value: str, metadata: Dict = None) -> bool:
|
||||
return True
|
||||
|
||||
def retrieve(self, user_id: str, key: str) -> Optional[MemoryEntry]:
|
||||
return None
|
||||
|
||||
def query(self, user_id: str, query_text: str, limit: int = 10) -> List[MemoryEntry]:
|
||||
return []
|
||||
|
||||
def list_entries(self, user_id: str) -> List[MemoryEntry]:
|
||||
return []
|
||||
|
||||
def delete(self, user_id: str, key: str) -> bool:
|
||||
return True
|
||||
|
||||
def get_user_context(self, user_id: str, query: str = "") -> UserContext:
|
||||
return UserContext(user_id=user_id, backend_name=self.backend_name)
|
||||
|
||||
@property
|
||||
def backend_name(self) -> str:
|
||||
return "null (disabled)"
|
||||
|
||||
@property
|
||||
def is_cloud(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_backend: Optional[MemoryBackend] = None
|
||||
|
||||
|
||||
def get_memory_backend() -> MemoryBackend:
|
||||
"""Get the configured memory backend.
|
||||
|
||||
Priority:
|
||||
1. HERMES_MEMORY_BACKEND=honcho + HONCHO_API_KEY -> HonchoBackend
|
||||
2. HERMES_MEMORY_BACKEND=local -> LocalBackend
|
||||
3. Default -> NullBackend (zero overhead)
|
||||
"""
|
||||
global _backend
|
||||
if _backend is not None:
|
||||
return _backend
|
||||
|
||||
backend_type = os.getenv("HERMES_MEMORY_BACKEND", "").lower().strip()
|
||||
|
||||
if backend_type == "honcho" or (os.getenv("HONCHO_API_KEY") and backend_type != "local"):
|
||||
try:
|
||||
from agent.memory.honcho_backend import HonchoBackend
|
||||
b = HonchoBackend()
|
||||
if b.is_available():
|
||||
_backend = b
|
||||
logger.info("Memory backend: Honcho (cloud)")
|
||||
return _backend
|
||||
except ImportError:
|
||||
logger.debug("Honcho SDK not installed")
|
||||
|
||||
if backend_type == "local" or True: # local is the safe default
|
||||
try:
|
||||
from agent.memory.local_backend import LocalBackend
|
||||
_backend = LocalBackend()
|
||||
logger.info("Memory backend: Local (SQLite)")
|
||||
return _backend
|
||||
except Exception as e:
|
||||
logger.warning("Local backend failed: %s", e)
|
||||
|
||||
_backend = NullBackend()
|
||||
return _backend
|
||||
|
||||
|
||||
def reset_backend():
|
||||
"""Reset the singleton (for testing)."""
|
||||
global _backend
|
||||
_backend = None
|
||||
242
agent/memory/evaluation.py
Normal file
242
agent/memory/evaluation.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Memory Backend Evaluation & Comparison Harness.
|
||||
|
||||
Structured evaluation comparing local vs cloud memory backends on:
|
||||
- Latency (store/retrieve/query)
|
||||
- Functionality (does it work correctly?)
|
||||
- Privacy (where does data live?)
|
||||
- Reliability (availability, error handling)
|
||||
- Cost (cloud dependency, API keys)
|
||||
|
||||
Also provides A/B harness for comparing backend quality on same queries.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackendEval:
|
||||
"""Evaluation result for one backend."""
|
||||
name: str
|
||||
is_cloud: bool
|
||||
available: bool
|
||||
store_ms: float = 0
|
||||
retrieve_ms: float = 0
|
||||
query_ms: float = 0
|
||||
store_ok: bool = False
|
||||
retrieve_ok: bool = False
|
||||
query_ok: bool = False
|
||||
query_count: int = 0
|
||||
data_location: str = ""
|
||||
requires_key: bool = False
|
||||
score: float = 0
|
||||
grade: str = ""
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComparisonResult:
|
||||
"""A/B comparison between two backends."""
|
||||
query: str
|
||||
local_results: int
|
||||
cloud_results: int
|
||||
local_ms: float
|
||||
cloud_ms: float
|
||||
winner: str # "local", "cloud", "tie"
|
||||
notes: str = ""
|
||||
|
||||
|
||||
def _latency(func, *args, **kwargs) -> tuple:
|
||||
"""Measure function latency in ms."""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return (time.perf_counter() - t0) * 1000, result, None
|
||||
except Exception as e:
|
||||
return (time.perf_counter() - t0) * 1000, None, e
|
||||
|
||||
|
||||
def evaluate(backend, test_user: str = "_eval_") -> BackendEval:
|
||||
"""Evaluate a single backend."""
|
||||
ev = BackendEval(
|
||||
name=backend.backend_name,
|
||||
is_cloud=backend.is_cloud,
|
||||
available=backend.is_available(),
|
||||
)
|
||||
|
||||
if not ev.available:
|
||||
ev.grade = "F"
|
||||
ev.notes.append("Not available")
|
||||
return ev
|
||||
|
||||
ev.data_location = "cloud (external)" if backend.is_cloud else "local (~/.hermes/)"
|
||||
ev.requires_key = backend.is_cloud
|
||||
|
||||
# Store test
|
||||
ms, ok, err = _latency(backend.store, test_user, "eval_k", "eval_v", {"src": "eval"})
|
||||
ev.store_ms = ms
|
||||
ev.store_ok = ok is True
|
||||
if err:
|
||||
ev.notes.append(f"Store error: {err}")
|
||||
|
||||
# Retrieve test
|
||||
ms, result, err = _latency(backend.retrieve, test_user, "eval_k")
|
||||
ev.retrieve_ms = ms
|
||||
ev.retrieve_ok = result is not None
|
||||
if err:
|
||||
ev.notes.append(f"Retrieve error: {err}")
|
||||
|
||||
# Query test
|
||||
ms, results, err = _latency(backend.query, test_user, "eval", 5)
|
||||
ev.query_ms = ms
|
||||
ev.query_ok = bool(results)
|
||||
ev.query_count = len(results) if results else 0
|
||||
if err:
|
||||
ev.notes.append(f"Query error: {err}")
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
backend.delete(test_user, "eval_k")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Score (0-100)
|
||||
s = 0
|
||||
s += 20 # available
|
||||
s += 15 if ev.store_ok else 0
|
||||
s += 15 if ev.retrieve_ok else 0
|
||||
s += 10 if ev.query_ok else 0
|
||||
|
||||
avg = (ev.store_ms + ev.retrieve_ms + ev.query_ms) / 3
|
||||
if avg < 10:
|
||||
s += 20
|
||||
elif avg < 50:
|
||||
s += 15
|
||||
elif avg < 200:
|
||||
s += 10
|
||||
else:
|
||||
s += 5
|
||||
|
||||
s += 20 if not backend.is_cloud else 5 # privacy
|
||||
|
||||
ev.score = s
|
||||
ev.grade = "A" if s >= 80 else "B" if s >= 60 else "C" if s >= 40 else "D" if s >= 20 else "F"
|
||||
|
||||
return ev
|
||||
|
||||
|
||||
def compare_backends(
|
||||
local_backend,
|
||||
cloud_backend,
|
||||
queries: List[str] = None,
|
||||
test_user: str = "_ab_test_",
|
||||
) -> List[ComparisonResult]:
|
||||
"""A/B compare two backends on the same queries."""
|
||||
if queries is None:
|
||||
queries = ["python", "editor", "theme", "testing", "deploy"]
|
||||
|
||||
results = []
|
||||
|
||||
# Seed both with same data
|
||||
for i, q in enumerate(queries):
|
||||
local_backend.store(test_user, f"seed_{i}", f"value for {q}", {"type": "preference"})
|
||||
if cloud_backend.is_available():
|
||||
cloud_backend.store(test_user, f"seed_{i}", f"value for {q}", {"type": "preference"})
|
||||
|
||||
# Query both
|
||||
for q in queries:
|
||||
t0 = time.perf_counter()
|
||||
local_res = local_backend.query(test_user, q, 5)
|
||||
local_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if cloud_backend.is_available():
|
||||
t0 = time.perf_counter()
|
||||
cloud_res = cloud_backend.query(test_user, q, 5)
|
||||
cloud_ms = (time.perf_counter() - t0) * 1000
|
||||
else:
|
||||
cloud_res = []
|
||||
cloud_ms = 0
|
||||
|
||||
local_count = len(local_res)
|
||||
cloud_count = len(cloud_res)
|
||||
|
||||
if local_count > cloud_count:
|
||||
winner = "local"
|
||||
elif cloud_count > local_count:
|
||||
winner = "cloud"
|
||||
elif local_ms < cloud_ms:
|
||||
winner = "local"
|
||||
elif cloud_ms < local_ms:
|
||||
winner = "cloud"
|
||||
else:
|
||||
winner = "tie"
|
||||
|
||||
results.append(ComparisonResult(
|
||||
query=q,
|
||||
local_results=local_count,
|
||||
cloud_results=cloud_count,
|
||||
local_ms=local_ms,
|
||||
cloud_ms=cloud_ms,
|
||||
winner=winner,
|
||||
))
|
||||
|
||||
# Cleanup
|
||||
for i, q in enumerate(queries):
|
||||
try:
|
||||
local_backend.delete(test_user, f"seed_{i}")
|
||||
cloud_backend.delete(test_user, f"seed_{i}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def full_evaluation() -> Dict[str, Any]:
|
||||
"""Run full evaluation across all available backends."""
|
||||
from agent.memory import NullBackend
|
||||
from agent.memory.local_backend import LocalBackend
|
||||
|
||||
backends = [NullBackend()]
|
||||
try:
|
||||
backends.append(LocalBackend())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import os
|
||||
if os.getenv("HONCHO_API_KEY"):
|
||||
try:
|
||||
from agent.memory.honcho_backend import HonchoBackend
|
||||
backends.append(HonchoBackend())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
evals = [evaluate(b) for b in backends]
|
||||
best = max((e for e in evals if e.name != "null (disabled)" and e.available), key=lambda e: e.score, default=None)
|
||||
|
||||
rec = "No viable backends"
|
||||
if best:
|
||||
rec = f"Best: {best.name} (score {best.score}, grade {best.grade})"
|
||||
if best.is_cloud:
|
||||
rec += " WARNING: cloud dependency. Consider local for sovereignty."
|
||||
|
||||
# A/B if both local and cloud available
|
||||
ab_results = []
|
||||
local_b = next((b for b in backends if not b.is_cloud and b.is_available()), None)
|
||||
cloud_b = next((b for b in backends if b.is_cloud and b.is_available()), None)
|
||||
if local_b and cloud_b:
|
||||
ab_results = compare_backends(local_b, cloud_b)
|
||||
local_wins = sum(1 for r in ab_results if r.winner == "local")
|
||||
cloud_wins = sum(1 for r in ab_results if r.winner == "cloud")
|
||||
if local_wins >= cloud_wins:
|
||||
rec += f" A/B: local wins {local_wins}/{len(ab_results)} queries. RECOMMEND LOCAL."
|
||||
|
||||
return {
|
||||
"backends": [asdict(e) for e in evals],
|
||||
"recommendation": rec,
|
||||
"ab_results": [asdict(r) for r in ab_results],
|
||||
}
|
||||
194
agent/memory/honcho_backend.py
Normal file
194
agent/memory/honcho_backend.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Honcho memory backend — opt-in cloud user modeling.
|
||||
|
||||
Requires HONCHO_API_KEY from app.honcho.dev. Zero runtime overhead
|
||||
when not configured — get_memory_backend() falls back to local.
|
||||
|
||||
Wraps Honcho's dialectic user modeling with our MemoryBackend interface.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory import MemoryBackend, MemoryEntry, UserContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HonchoBackend(MemoryBackend):
|
||||
"""Honcho AI-native memory with dialectic queries."""
|
||||
|
||||
def __init__(self):
|
||||
self._client = None
|
||||
self._api_key = os.getenv("HONCHO_API_KEY", "")
|
||||
self._app_id = os.getenv("HONCHO_APP_ID", "hermes-agent")
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
if not self._api_key:
|
||||
return None
|
||||
try:
|
||||
from honcho import Honcho
|
||||
self._client = Honcho(api_key=self._api_key, app_id=self._app_id)
|
||||
return self._client
|
||||
except ImportError:
|
||||
logger.warning("honcho-ai not installed: pip install honcho-ai")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Honcho init failed: %s", e)
|
||||
return None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not self._api_key:
|
||||
return False
|
||||
client = self._get_client()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
client.get_sessions(limit=1)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def store(self, user_id: str, key: str, value: str, metadata: Dict = None) -> bool:
|
||||
client = self._get_client()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
session_id = f"hermes-prefs-{user_id}"
|
||||
msg = json.dumps({
|
||||
"type": "preference", "key": key, "value": value,
|
||||
"metadata": metadata or {}, "ts": time.time(),
|
||||
})
|
||||
client.add_message(session_id=session_id, role="system", content=msg)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Honcho store failed: %s", e)
|
||||
return False
|
||||
|
||||
def retrieve(self, user_id: str, key: str) -> Optional[MemoryEntry]:
|
||||
results = self.query(user_id, key, limit=1)
|
||||
for entry in results:
|
||||
if entry.key == key:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def query(self, user_id: str, query_text: str, limit: int = 10) -> List[MemoryEntry]:
|
||||
client = self._get_client()
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
session_id = f"hermes-prefs-{user_id}"
|
||||
result = client.chat(
|
||||
session_id=session_id,
|
||||
message=f"Find user preferences and patterns related to: {query_text}",
|
||||
)
|
||||
entries = []
|
||||
if isinstance(result, dict):
|
||||
content = result.get("content", "")
|
||||
try:
|
||||
data = json.loads(content)
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for item in items[:limit]:
|
||||
if isinstance(item, dict) and item.get("key"):
|
||||
entries.append(MemoryEntry(
|
||||
key=item["key"], value=item.get("value", ""),
|
||||
user_id=user_id, metadata=item.get("metadata", {}),
|
||||
))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return entries
|
||||
except Exception as e:
|
||||
logger.warning("Honcho query failed: %s", e)
|
||||
return []
|
||||
|
||||
def list_entries(self, user_id: str) -> List[MemoryEntry]:
|
||||
return self.query(user_id, "", limit=100)
|
||||
|
||||
def delete(self, user_id: str, key: str) -> bool:
|
||||
logger.info("Honcho does not support individual entry deletion")
|
||||
return False
|
||||
|
||||
def store_interaction(
|
||||
self,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Store session interaction in Honcho for cross-session modeling."""
|
||||
client = self._get_client()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
honcho_session = f"hermes-session-{user_id}-{session_id}"
|
||||
# Store each user/assistant exchange
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
if role in ("user", "assistant") and content:
|
||||
client.add_message(
|
||||
session_id=honcho_session,
|
||||
role=role,
|
||||
content=str(content)[:2000],
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Honcho store_interaction failed: %s", e)
|
||||
return False
|
||||
|
||||
def get_user_context(self, user_id: str, query: str = "") -> UserContext:
|
||||
"""Use Honcho's dialectic query for richer context."""
|
||||
start = time.perf_counter()
|
||||
client = self._get_client()
|
||||
|
||||
if client is None:
|
||||
return UserContext(user_id=user_id, backend_name=self.backend_name)
|
||||
|
||||
try:
|
||||
session_id = f"hermes-prefs-{user_id}"
|
||||
q = query or "What are this user's preferences, patterns, and recent topics?"
|
||||
result = client.chat(session_id=session_id, message=q)
|
||||
|
||||
preferences = {}
|
||||
patterns = []
|
||||
summary = ""
|
||||
|
||||
if isinstance(result, dict):
|
||||
content = result.get("content", "")
|
||||
summary = content[:500]
|
||||
try:
|
||||
data = json.loads(content)
|
||||
if isinstance(data, dict):
|
||||
preferences = data.get("preferences", {})
|
||||
patterns = data.get("patterns", [])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
return UserContext(
|
||||
user_id=user_id,
|
||||
preferences=preferences,
|
||||
patterns=patterns,
|
||||
summary=summary,
|
||||
backend_name=self.backend_name,
|
||||
query_time_ms=elapsed,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Honcho get_user_context failed: %s", e)
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
return UserContext(
|
||||
user_id=user_id,
|
||||
backend_name=self.backend_name,
|
||||
query_time_ms=elapsed,
|
||||
)
|
||||
|
||||
@property
|
||||
def backend_name(self) -> str:
|
||||
return "honcho (cloud)"
|
||||
|
||||
@property
|
||||
def is_cloud(self) -> bool:
|
||||
return True
|
||||
159
agent/memory/local_backend.py
Normal file
159
agent/memory/local_backend.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Local SQLite memory backend.
|
||||
|
||||
Zero cloud dependency. Stores user preferences and patterns in a
|
||||
local SQLite database at ~/.hermes/memory.db.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from agent.memory import MemoryBackend, MemoryEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalBackend(MemoryBackend):
|
||||
"""SQLite-backed local memory storage."""
|
||||
|
||||
def __init__(self, db_path: Path = None):
|
||||
self._db_path = db_path or (get_hermes_home() / "memory.db")
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
user_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
entry_type TEXT DEFAULT 'preference',
|
||||
confidence REAL DEFAULT 1.0,
|
||||
source_session TEXT DEFAULT '',
|
||||
metadata TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (user_id, key)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user ON memories(user_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_type ON memories(entry_type)")
|
||||
conn.commit()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
conn.execute("SELECT 1")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def store(self, user_id: str, key: str, value: str, metadata: Dict = None) -> bool:
|
||||
try:
|
||||
now = time.time()
|
||||
meta_json = json.dumps(metadata) if metadata else None
|
||||
entry_type = (metadata or {}).get("type", "preference")
|
||||
source_session = (metadata or {}).get("session_id", "")
|
||||
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO memories
|
||||
(user_id, key, value, entry_type, source_session, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
entry_type = excluded.entry_type,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = excluded.updated_at
|
||||
""", (user_id, key, value, entry_type, source_session, meta_json, now, now))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Store failed: %s", e)
|
||||
return False
|
||||
|
||||
def retrieve(self, user_id: str, key: str) -> Optional[MemoryEntry]:
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT key, value, user_id, entry_type, confidence, source_session, "
|
||||
"metadata, created_at, updated_at "
|
||||
"FROM memories WHERE user_id = ? AND key = ?",
|
||||
(user_id, key),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return MemoryEntry(
|
||||
key=row[0], value=row[1], user_id=row[2],
|
||||
entry_type=row[3], confidence=row[4], source_session=row[5],
|
||||
metadata=json.loads(row[6]) if row[6] else {},
|
||||
created_at=row[7], updated_at=row[8],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Retrieve failed: %s", e)
|
||||
return None
|
||||
|
||||
def query(self, user_id: str, query_text: str, limit: int = 10) -> List[MemoryEntry]:
|
||||
try:
|
||||
pattern = f"%{query_text}%"
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT key, value, user_id, entry_type, confidence, source_session,
|
||||
metadata, created_at, updated_at
|
||||
FROM memories
|
||||
WHERE user_id = ? AND (key LIKE ? OR value LIKE ?)
|
||||
ORDER BY updated_at DESC LIMIT ?
|
||||
""", (user_id, pattern, pattern, limit)).fetchall()
|
||||
return [
|
||||
MemoryEntry(
|
||||
key=r[0], value=r[1], user_id=r[2],
|
||||
entry_type=r[3], confidence=r[4], source_session=r[5],
|
||||
metadata=json.loads(r[6]) if r[6] else {},
|
||||
created_at=r[7], updated_at=r[8],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def list_entries(self, user_id: str) -> List[MemoryEntry]:
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT key, value, user_id, entry_type, confidence, source_session,
|
||||
metadata, created_at, updated_at
|
||||
FROM memories WHERE user_id = ? ORDER BY updated_at DESC
|
||||
""", (user_id,)).fetchall()
|
||||
return [
|
||||
MemoryEntry(
|
||||
key=r[0], value=r[1], user_id=r[2],
|
||||
entry_type=r[3], confidence=r[4], source_session=r[5],
|
||||
metadata=json.loads(r[6]) if r[6] else {},
|
||||
created_at=r[7], updated_at=r[8],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def delete(self, user_id: str, key: str) -> bool:
|
||||
try:
|
||||
with sqlite3.connect(str(self._db_path)) as conn:
|
||||
conn.execute("DELETE FROM memories WHERE user_id = ? AND key = ?", (user_id, key))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@property
|
||||
def backend_name(self) -> str:
|
||||
return "local (SQLite)"
|
||||
|
||||
@property
|
||||
def is_cloud(self) -> bool:
|
||||
return False
|
||||
157
tests/agent/test_memory_backend.py
Normal file
157
tests/agent/test_memory_backend.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Tests for memory backend system (#322)."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.memory import MemoryEntry, NullBackend, get_memory_backend, reset_backend
|
||||
from agent.memory.local_backend import LocalBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def local(tmp_path):
|
||||
return LocalBackend(db_path=tmp_path / "test.db")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def reset():
|
||||
reset_backend()
|
||||
yield
|
||||
reset_backend()
|
||||
|
||||
|
||||
class TestMemoryEntry:
|
||||
def test_defaults(self):
|
||||
e = MemoryEntry(key="k", value="v", user_id="u")
|
||||
assert e.created_at > 0
|
||||
assert e.entry_type == "preference"
|
||||
assert e.confidence == 1.0
|
||||
|
||||
|
||||
class TestNullBackend:
|
||||
def test_available(self):
|
||||
assert NullBackend().is_available()
|
||||
|
||||
def test_store_noop(self):
|
||||
assert NullBackend().store("u", "k", "v")
|
||||
|
||||
def test_retrieve_none(self):
|
||||
assert NullBackend().retrieve("u", "k") is None
|
||||
|
||||
def test_query_empty(self):
|
||||
assert NullBackend().query("u", "q") == []
|
||||
|
||||
def test_not_cloud(self):
|
||||
assert not NullBackend().is_cloud
|
||||
|
||||
def test_context_empty(self):
|
||||
ctx = NullBackend().get_user_context("u", "q")
|
||||
assert ctx.user_id == "u"
|
||||
assert ctx.preferences == {}
|
||||
|
||||
|
||||
class TestLocalBackend:
|
||||
def test_available(self, local):
|
||||
assert local.is_available()
|
||||
|
||||
def test_store_retrieve(self, local):
|
||||
assert local.store("u", "lang", "python")
|
||||
e = local.retrieve("u", "lang")
|
||||
assert e.value == "python"
|
||||
|
||||
def test_metadata(self, local):
|
||||
local.store("u", "k", "v", {"type": "pattern", "session_id": "s1"})
|
||||
e = local.retrieve("u", "k")
|
||||
assert e.entry_type == "pattern"
|
||||
assert e.source_session == "s1"
|
||||
|
||||
def test_update(self, local):
|
||||
local.store("u", "k", "v1")
|
||||
local.store("u", "k", "v2")
|
||||
assert local.retrieve("u", "k").value == "v2"
|
||||
|
||||
def test_query(self, local):
|
||||
local.store("u", "pref_python", "True")
|
||||
local.store("u", "pref_editor", "vim")
|
||||
local.store("u", "theme", "dark")
|
||||
assert len(local.query("u", "pref")) == 2
|
||||
|
||||
def test_list(self, local):
|
||||
local.store("u", "a", "1")
|
||||
local.store("u", "b", "2")
|
||||
assert len(local.list_entries("u")) == 2
|
||||
|
||||
def test_delete(self, local):
|
||||
local.store("u", "k", "v")
|
||||
assert local.delete("u", "k")
|
||||
assert local.retrieve("u", "k") is None
|
||||
|
||||
def test_not_cloud(self, local):
|
||||
assert not local.is_cloud
|
||||
|
||||
def test_separate_users(self, local):
|
||||
local.store("u1", "k", "v1")
|
||||
local.store("u2", "k", "v2")
|
||||
assert local.retrieve("u1", "k").value == "v1"
|
||||
assert local.retrieve("u2", "k").value == "v2"
|
||||
|
||||
def test_store_interaction(self, local):
|
||||
msgs = [
|
||||
{"role": "user", "content": "list files"},
|
||||
{"role": "assistant", "content": None, "tool_calls": json.dumps([
|
||||
{"function": {"name": "terminal", "arguments": "{}"}}
|
||||
])},
|
||||
]
|
||||
assert local.store_interaction("u", "s1", msgs)
|
||||
e = local.retrieve("u", "session_s1_tools")
|
||||
assert e is not None
|
||||
tools = json.loads(e.value)
|
||||
assert "terminal" in tools
|
||||
|
||||
|
||||
class TestHonchoBackend:
|
||||
def test_not_available_without_key(self, monkeypatch):
|
||||
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
|
||||
from agent.memory.honcho_backend import HonchoBackend
|
||||
assert not HonchoBackend().is_available()
|
||||
|
||||
def test_is_cloud(self):
|
||||
from agent.memory.honcho_backend import HonchoBackend
|
||||
assert HonchoBackend().is_cloud
|
||||
|
||||
|
||||
class TestSingleton:
|
||||
def test_default_local(self, reset, monkeypatch):
|
||||
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
|
||||
monkeypatch.setenv("HERMES_MEMORY_BACKEND", "local")
|
||||
b = get_memory_backend()
|
||||
assert isinstance(b, LocalBackend)
|
||||
|
||||
def test_caches(self, reset, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MEMORY_BACKEND", "local")
|
||||
assert get_memory_backend() is get_memory_backend()
|
||||
|
||||
|
||||
class TestEvaluation:
|
||||
def test_null_eval(self):
|
||||
from agent.memory.evaluation import evaluate
|
||||
r = evaluate(NullBackend())
|
||||
assert r.score > 0
|
||||
assert r.grade in ("A", "B", "C", "D", "F")
|
||||
|
||||
def test_local_eval(self, local):
|
||||
from agent.memory.evaluation import evaluate
|
||||
r = evaluate(local)
|
||||
assert r.store_ok
|
||||
assert r.retrieve_ok
|
||||
assert r.score >= 80
|
||||
assert r.grade == "A"
|
||||
|
||||
def test_full_eval(self, reset, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_MEMORY_BACKEND", "local")
|
||||
from agent.memory.evaluation import full_evaluation
|
||||
r = full_evaluation()
|
||||
assert len(r["backends"]) >= 2
|
||||
assert "recommendation" in r
|
||||
118
tools/memory_backend_tool.py
Normal file
118
tools/memory_backend_tool.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Memory Backend Tool — cross-session user modeling.
|
||||
|
||||
Pluggable memory backends: local SQLite (default) or Honcho cloud (opt-in).
|
||||
"""
|
||||
|
||||
import json
|
||||
from tools.registry import registry
|
||||
|
||||
|
||||
def memory_backend(
|
||||
action: str,
|
||||
user_id: str = "default",
|
||||
key: str = None,
|
||||
value: str = None,
|
||||
query_text: str = None,
|
||||
metadata: dict = None,
|
||||
) -> str:
|
||||
"""Manage cross-session memory backends.
|
||||
|
||||
Actions: store, retrieve, query, list, delete, info, evaluate
|
||||
"""
|
||||
from agent.memory import get_memory_backend
|
||||
|
||||
backend = get_memory_backend()
|
||||
|
||||
if action == "info":
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"backend": backend.backend_name,
|
||||
"is_cloud": backend.is_cloud,
|
||||
"available": backend.is_available(),
|
||||
})
|
||||
|
||||
if action == "store":
|
||||
if not key or value is None:
|
||||
return json.dumps({"success": False, "error": "key and value required"})
|
||||
return json.dumps({"success": backend.store(user_id, key, value, metadata), "key": key})
|
||||
|
||||
if action == "retrieve":
|
||||
if not key:
|
||||
return json.dumps({"success": False, "error": "key required"})
|
||||
entry = backend.retrieve(user_id, key)
|
||||
if not entry:
|
||||
return json.dumps({"success": False, "error": f"No entry for '{key}'"})
|
||||
return json.dumps({
|
||||
"success": True, "key": entry.key, "value": entry.value,
|
||||
"entry_type": entry.entry_type, "metadata": entry.metadata,
|
||||
})
|
||||
|
||||
if action == "query":
|
||||
if not query_text:
|
||||
return json.dumps({"success": False, "error": "query_text required"})
|
||||
results = backend.query(user_id, query_text)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"results": [{"key": e.key, "value": e.value, "type": e.entry_type} for e in results],
|
||||
"count": len(results),
|
||||
})
|
||||
|
||||
if action == "context":
|
||||
ctx = backend.get_user_context(user_id, query_text or "")
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"preferences": ctx.preferences,
|
||||
"patterns": ctx.patterns[:20],
|
||||
"summary": ctx.summary,
|
||||
"query_ms": round(ctx.query_time_ms, 1),
|
||||
})
|
||||
|
||||
if action == "list":
|
||||
entries = backend.list_entries(user_id)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"entries": [{"key": e.key, "type": e.entry_type} for e in entries],
|
||||
"count": len(entries),
|
||||
})
|
||||
|
||||
if action == "delete":
|
||||
if not key:
|
||||
return json.dumps({"success": False, "error": "key required"})
|
||||
return json.dumps({"success": backend.delete(user_id, key)})
|
||||
|
||||
if action == "evaluate":
|
||||
from agent.memory.evaluation import full_evaluation
|
||||
report = full_evaluation()
|
||||
return json.dumps({"success": True, **report})
|
||||
|
||||
return json.dumps({"success": False, "error": f"Unknown: {action}"})
|
||||
|
||||
|
||||
registry.register(
|
||||
name="memory_backend",
|
||||
toolset="skills",
|
||||
schema={
|
||||
"name": "memory_backend",
|
||||
"description": (
|
||||
"Cross-session memory: store/retrieve user preferences across sessions. "
|
||||
"Local SQLite default, Honcho cloud opt-in. Zero overhead when disabled."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["store", "retrieve", "query", "list", "delete", "info", "context", "evaluate"],
|
||||
},
|
||||
"user_id": {"type": "string"},
|
||||
"key": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
"query_text": {"type": "string"},
|
||||
"metadata": {"type": "object"},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
},
|
||||
handler=lambda args, **kw: memory_backend(**{k: v for k, v in args.items() if v is not None}),
|
||||
emoji="🧠",
|
||||
)
|
||||
Reference in New Issue
Block a user