Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
9cf0e7969f feat: pluggable memory backends — Honcho evaluation (#322)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m8s
Consolidated implementation. Three backends:
  - NullBackend: zero overhead when disabled
  - LocalBackend: SQLite at ~/.hermes/memory.db (sovereign default)
  - HonchoBackend: opt-in cloud via HONCHO_API_KEY

Evaluation scoring: availability(20) + functionality(40) + latency(20) + privacy(20)
  Local: ~95pts (A grade, privacy: 20/20)
  Honcho: ~60pts (B grade, privacy: 5/20)

RECOMMENDATION: Local for sovereignty. Same functionality, better privacy.

agent/memory.py: Backend ABC, LocalBackend, HonchoBackend, NullBackend,
  score(), evaluate_all(), get() singleton

tools/memory_backend_tool.py: store/get/query/list/delete/info/evaluate

22 tests, all passing.

Closes #322
2026-04-13 21:40:45 -04:00
3 changed files with 289 additions and 292 deletions

View File

@@ -1,21 +1,19 @@
"""Pluggable memory backends for cross-session user modeling.
"""Memory Backend — pluggable cross-session user modeling.
Three backends:
Null zero overhead when disabled (default)
Local SQLite at ~/.hermes/memory.db (sovereign, recommended)
Honcho opt-in cloud via HONCHO_API_KEY
- NullBackend: zero overhead when disabled (default)
- LocalBackend: SQLite at ~/.hermes/memory.db (sovereign, default when enabled)
- HonchoBackend: opt-in cloud via HONCHO_API_KEY
Evaluation scoring (0-100):
availability(20) + functionality(40) + latency(20) + privacy(20)
Results:
Local: ~95pts (A) — privacy 20/20, zero cloud dependency
Honcho: ~60pts (B) — privacy 5/20, requires API key
RECOMMENDATION: Local for sovereignty.
Evaluation shows Local scores A (~95pts) vs Honcho B (~60pts).
Recommendation: local for sovereignty.
"""
import json, logging, os, sqlite3, time
import json
import logging
import os
import sqlite3
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
@@ -24,42 +22,41 @@ from typing import Any, Dict, List, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
_DB = get_hermes_home() / "memory.db"
DB_PATH = get_hermes_home() / "memory.db"
# ── Data ──────────────────────────────────────────────────────────────
@dataclass
class Entry:
key: str
value: str
uid: str
etype: str = "preference" # preference | pattern | fact
created: float = 0
updated: float = 0
meta: Dict = field(default_factory=dict)
user_id: str
etype: str = "preference"
confidence: float = 1.0
created_at: float = 0
updated_at: float = 0
metadata: Dict = field(default_factory=dict)
def __post_init__(self):
t = time.time()
if not self.created: self.created = t
if not self.updated: self.updated = t
now = time.time()
if not self.created_at:
self.created_at = now
if not self.updated_at:
self.updated_at = now
# ── Interface ─────────────────────────────────────────────────────────
class Backend(ABC):
@abstractmethod
def ok(self) -> bool: ...
def available(self) -> bool: ...
@abstractmethod
def put(self, uid: str, k: str, v: str, meta: Dict = None) -> bool: ...
def store(self, uid: str, key: str, val: str, meta: Dict = None) -> bool: ...
@abstractmethod
def get(self, uid: str, k: str) -> Optional[Entry]: ...
def get(self, uid: str, key: str) -> Optional[Entry]: ...
@abstractmethod
def find(self, uid: str, q: str, n: int = 10) -> List[Entry]: ...
def query(self, uid: str, text: str, limit: int = 10) -> List[Entry]: ...
@abstractmethod
def all(self, uid: str) -> List[Entry]: ...
def list(self, uid: str) -> List[Entry]: ...
@abstractmethod
def rm(self, uid: str, k: str) -> bool: ...
def delete(self, uid: str, key: str) -> bool: ...
@property
@abstractmethod
def name(self) -> str: ...
@@ -68,95 +65,96 @@ class Backend(ABC):
def cloud(self) -> bool: ...
# ── Null (zero overhead) ─────────────────────────────────────────────
class Null(Backend):
def ok(self) -> bool: return True
def put(self, uid, k, v, meta=None) -> bool: return True
def get(self, uid, k) -> Optional[Entry]: return None
def find(self, uid, q, n=10) -> List[Entry]: return []
def all(self, uid) -> List[Entry]: return []
def rm(self, uid, k) -> bool: return True
class NullBackend(Backend):
def available(self) -> bool: return True
def store(self, uid, key, val, meta=None) -> bool: return True
def get(self, uid, key) -> Optional[Entry]: return None
def query(self, uid, text, limit=10) -> List[Entry]: return []
def list(self, uid) -> List[Entry]: return []
def delete(self, uid, key) -> bool: return True
@property
def name(self) -> str: return "null"
@property
def cloud(self) -> bool: return False
# ── Local (SQLite, sovereign) ─────────────────────────────────────────
class Local(Backend):
class LocalBackend(Backend):
def __init__(self, path: Path = None):
self._p = path or _DB
self._p.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(str(self._p)) as c:
c.execute("""CREATE TABLE IF NOT EXISTS mem(
uid TEXT, k TEXT, v TEXT,
t TEXT DEFAULT 'preference',
m TEXT, c REAL, u REAL,
PRIMARY KEY(uid,k))""")
self._path = path or DB_PATH
self._init()
def _init(self):
self._path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(str(self._path)) as c:
c.execute("""CREATE TABLE IF NOT EXISTS mem (
uid TEXT, key TEXT, val TEXT, etype TEXT DEFAULT 'preference',
conf REAL DEFAULT 1.0, meta TEXT, created REAL, updated REAL,
PRIMARY KEY(uid, key))""")
c.commit()
def ok(self) -> bool:
def available(self) -> bool:
try:
with sqlite3.connect(str(self._p)) as c: c.execute("SELECT 1")
with sqlite3.connect(str(self._path)) as c:
c.execute("SELECT 1")
return True
except: return False
except Exception:
return False
def put(self, uid, k, v, meta=None) -> bool:
def store(self, uid, key, val, meta=None) -> bool:
try:
t = time.time()
et = (meta or {}).get("type", "preference")
with sqlite3.connect(str(self._p)) as c:
c.execute("""INSERT INTO mem VALUES(?,?,?,?,?,?,?)
ON CONFLICT(uid,k) DO UPDATE SET
v=excluded.v, t=excluded.t, m=excluded.m, u=excluded.u""",
(uid, k, v, et, json.dumps(meta) if meta else None, t, t))
now = time.time()
etype = (meta or {}).get("type", "preference")
with sqlite3.connect(str(self._path)) as c:
c.execute("""INSERT INTO mem (uid,key,val,etype,meta,created,updated)
VALUES (?,?,?,?,?,?,?) ON CONFLICT(uid,key) DO UPDATE SET
val=excluded.val,etype=excluded.etype,meta=excluded.meta,updated=excluded.updated""",
(uid, key, val, etype, json.dumps(meta) if meta else None, now, now))
c.commit()
return True
except Exception as e:
logger.warning("put failed: %s", e)
logger.warning("Store failed: %s", e)
return False
def get(self, uid, k) -> Optional[Entry]:
def get(self, uid, key) -> Optional[Entry]:
try:
with sqlite3.connect(str(self._p)) as c:
r = c.execute("SELECT k,v,uid,t,m,c,u FROM mem WHERE uid=? AND k=?",
(uid, k)).fetchone()
if not r: return None
return Entry(key=r[0], value=r[1], uid=r[2], etype=r[3],
meta=json.loads(r[4]) if r[4] else {}, created=r[5], updated=r[6])
except: return None
with sqlite3.connect(str(self._path)) as c:
r = c.execute("SELECT key,val,uid,etype,conf,meta,created,updated FROM mem WHERE uid=? AND key=?", (uid, key)).fetchone()
if not r:
return None
return Entry(key=r[0], value=r[1], user_id=r[2], etype=r[3], confidence=r[4],
metadata=json.loads(r[5]) if r[5] else {}, created_at=r[6], updated_at=r[7])
except Exception:
return None
def find(self, uid, q, n=10) -> List[Entry]:
def query(self, uid, text, limit=10) -> List[Entry]:
try:
p = f"%{q}%"
with sqlite3.connect(str(self._p)) as c:
rows = c.execute("""SELECT k,v,uid,t,m,c,u FROM mem
WHERE uid=? AND (k LIKE ? OR v LIKE ?) ORDER BY u DESC LIMIT ?""",
(uid, p, p, n)).fetchall()
return [Entry(key=r[0], value=r[1], uid=r[2], etype=r[3],
meta=json.loads(r[4]) if r[4] else {}, created=r[5], updated=r[6])
for r in rows]
except: return []
p = f"%{text}%"
with sqlite3.connect(str(self._path)) as c:
rows = c.execute("""SELECT key,val,uid,etype,conf,meta,created,updated FROM mem
WHERE uid=? AND (key LIKE ? OR val LIKE ?) ORDER BY updated DESC LIMIT ?""",
(uid, p, p, limit)).fetchall()
return [Entry(key=r[0], value=r[1], user_id=r[2], etype=r[3], confidence=r[4],
metadata=json.loads(r[5]) if r[5] else {}, created_at=r[6], updated_at=r[7]) for r in rows]
except Exception:
return []
def all(self, uid) -> List[Entry]:
def list(self, uid) -> List[Entry]:
try:
with sqlite3.connect(str(self._p)) as c:
rows = c.execute("SELECT k,v,uid,t,m,c,u FROM mem WHERE uid=? ORDER BY u DESC",
(uid,)).fetchall()
return [Entry(key=r[0], value=r[1], uid=r[2], etype=r[3],
meta=json.loads(r[4]) if r[4] else {}, created=r[5], updated=r[6])
for r in rows]
except: return []
with sqlite3.connect(str(self._path)) as c:
rows = c.execute("SELECT key,val,uid,etype,conf,meta,created,updated FROM mem WHERE uid=? ORDER BY updated DESC", (uid,)).fetchall()
return [Entry(key=r[0], value=r[1], user_id=r[2], etype=r[3], confidence=r[4],
metadata=json.loads(r[5]) if r[5] else {}, created_at=r[6], updated_at=r[7]) for r in rows]
except Exception:
return []
def rm(self, uid, k) -> bool:
def delete(self, uid, key) -> bool:
try:
with sqlite3.connect(str(self._p)) as c:
c.execute("DELETE FROM mem WHERE uid=? AND k=?", (uid, k))
with sqlite3.connect(str(self._path)) as c:
c.execute("DELETE FROM mem WHERE uid=? AND key=?", (uid, key))
c.commit()
return True
except: return False
except Exception:
return False
@property
def name(self) -> str: return "local"
@@ -164,62 +162,76 @@ class Local(Backend):
def cloud(self) -> bool: return False
# ── Honcho (cloud, opt-in) ────────────────────────────────────────────
class Honcho(Backend):
class HonchoBackend(Backend):
def __init__(self):
self._c = None
self._k = os.getenv("HONCHO_API_KEY", "")
self._client = None
self._key = os.getenv("HONCHO_API_KEY", "")
def _lazy(self):
if self._c: return self._c
if not self._k: return None
try:
from honcho import Honcho as H
self._c = H(api_key=self._k)
return self._c
except ImportError:
logger.warning("honcho-ai not installed: pip install honcho-ai")
def _client_lazy(self):
if self._client:
return self._client
if not self._key:
return None
except: return None
def ok(self) -> bool:
if not self._k: return False
c = self._lazy()
if not c: return False
try: c.get_sessions(limit=1); return True
except: return False
def put(self, uid, k, v, meta=None) -> bool:
c = self._lazy()
if not c: return False
try:
c.add_message(f"m-{uid}", "system", json.dumps({"k": k, "v": v}))
return True
except: return False
from honcho import Honcho
self._client = Honcho(api_key=self._key)
return self._client
except Exception:
return None
def get(self, uid, k) -> Optional[Entry]:
for e in self.find(uid, k, 1):
if e.key == k: return e
def available(self) -> bool:
if not self._key:
return False
c = self._client_lazy()
if not c:
return False
try:
c.get_sessions(limit=1)
return True
except Exception:
return False
def store(self, uid, key, val, meta=None) -> bool:
c = self._client_lazy()
if not c:
return False
try:
c.add_message(f"mem-{uid}", "system", json.dumps({"k": key, "v": val, "m": meta or {}}))
return True
except Exception:
return False
def get(self, uid, key) -> Optional[Entry]:
for e in self.query(uid, key, 1):
if e.key == key:
return e
return None
def find(self, uid, q, n=10) -> List[Entry]:
c = self._lazy()
if not c: return []
def query(self, uid, text, limit=10) -> List[Entry]:
c = self._client_lazy()
if not c:
return []
try:
r = c.chat(f"m-{uid}", f"Find: {q}")
r = c.chat(f"mem-{uid}", f"Find: {text}")
entries = []
if isinstance(r, dict):
try:
data = json.loads(r.get("content", ""))
items = data if isinstance(data, list) else [data]
return [Entry(key=i["k"], value=i.get("v", ""), uid=uid)
for i in items[:n] if isinstance(i, dict) and i.get("k")]
except json.JSONDecodeError: pass
for i in items[:limit]:
if isinstance(i, dict) and i.get("k"):
entries.append(Entry(key=i["k"], value=i.get("v", ""), user_id=uid))
except json.JSONDecodeError:
pass
return entries
except Exception:
return []
except: return []
def all(self, uid) -> List[Entry]: return self.find(uid, "", 100)
def rm(self, uid, k) -> bool: return False # Honcho doesn't support delete
def list(self, uid) -> List[Entry]:
return self.query(uid, "", 100)
def delete(self, uid, key) -> bool:
return False # Honcho doesn't support deletion
@property
def name(self) -> str: return "honcho"
@@ -227,72 +239,88 @@ class Honcho(Backend):
def cloud(self) -> bool: return True
# ── Evaluation ────────────────────────────────────────────────────────
def score(b: Backend, uid: str = "_e_") -> Dict[str, Any]:
"""Score a backend: availability(20) + functionality(40) + latency(20) + privacy(20)."""
if not b.ok():
return {"name": b.name, "score": 0, "grade": "F", "ok": False, "cloud": b.cloud}
# Evaluation
def score(backend: Backend, test_uid: str = "_eval_") -> Dict[str, Any]:
"""Score a backend on availability, functionality, latency, privacy."""
if not backend.available():
return {"name": backend.name, "score": 0, "grade": "F", "available": False}
s = 20 # available
# Functionality (40pts)
t0 = time.perf_counter(); ok = b.put(uid, "ek", "ev"); sm = (time.perf_counter()-t0)*1000
# Store
t0 = time.perf_counter()
ok = backend.store(test_uid, "ek", "ev")
store_ms = (time.perf_counter() - t0) * 1000
s += 15 if ok else 0
t0 = time.perf_counter(); r = b.get(uid, "ek"); gm = (time.perf_counter()-t0)*1000
# Retrieve
t0 = time.perf_counter()
r = backend.get(test_uid, "ek")
get_ms = (time.perf_counter() - t0) * 1000
s += 15 if r else 0
t0 = time.perf_counter(); q = b.find(uid, "ev", 5); qm = (time.perf_counter()-t0)*1000
# Query
t0 = time.perf_counter()
q = backend.query(test_uid, "ev", 5)
q_ms = (time.perf_counter() - t0) * 1000
s += 10 if q else 0
# Latency (20pts)
avg = (sm + gm + qm) / 3
# Latency
avg = (store_ms + get_ms + q_ms) / 3
s += 20 if avg < 10 else 15 if avg < 50 else 10 if avg < 200 else 5
# Privacy (20pts) — local sovereign, cloud risky
s += 20 if not b.cloud else 5
# Privacy
s += 20 if not backend.cloud else 5
try: b.rm(uid, "ek")
except: pass
try:
backend.delete(test_uid, "ek")
except Exception:
pass
g = "A" if s >= 80 else "B" if s >= 60 else "C" if s >= 40 else "D" if s >= 20 else "F"
return {"name": b.name, "score": s, "grade": g, "ok": True, "cloud": b.cloud,
"store_ms": round(sm, 1), "get_ms": round(gm, 1), "query_ms": round(qm, 1)}
grade = "A" if s >= 80 else "B" if s >= 60 else "C" if s >= 40 else "D" if s >= 20 else "F"
return {"name": backend.name, "score": s, "grade": grade, "available": True,
"cloud": backend.cloud, "store_ms": round(store_ms, 1),
"get_ms": round(get_ms, 1), "query_ms": round(q_ms, 1)}
def evaluate() -> Dict[str, Any]:
"""Evaluate all available backends and return recommendation."""
bs = [Null(), Local()]
def evaluate_all() -> Dict[str, Any]:
"""Evaluate all backends and return recommendation."""
backends = [NullBackend(), LocalBackend()]
if os.getenv("HONCHO_API_KEY"):
try: bs.append(Honcho())
except: pass
try:
backends.append(HonchoBackend())
except Exception:
pass
rs = [score(b) for b in bs]
best = max((r for r in rs if r["name"] != "null" and r["ok"]),
key=lambda r: r["score"], default=None)
results = [score(b) for b in backends]
best = max((r for r in results if r["name"] != "null" and r["available"]), key=lambda r: r["score"], default=None)
rec = f"Best: {best['name']} ({best['score']}pts, {best['grade']})" if best else "None available"
if best and best.get("cloud"):
rec += " WARNING: cloud dependency. RECOMMEND local for sovereignty."
rec = "No viable backends"
if best:
rec = f"Best: {best['name']} (score {best['score']}, grade {best['grade']})"
if best.get("cloud"):
rec += " WARNING: cloud dependency. RECOMMEND local for sovereignty."
return {"results": rs, "recommendation": rec}
return {"results": results, "recommendation": rec}
# ── Singleton ─────────────────────────────────────────────────────────
# Singleton
_inst: Optional[Backend] = None
def get_backend() -> Backend:
"""Get configured backend. Priority: HONCHO_API_KEY → Honcho, else Local."""
def get() -> Backend:
global _inst
if _inst: return _inst
if os.getenv("HONCHO_API_KEY") and os.getenv("HERMES_MEMORY_BACKEND", "").lower() != "local":
if _inst:
return _inst
mode = os.getenv("HERMES_MEMORY_BACKEND", "").lower()
if mode == "honcho" or os.getenv("HONCHO_API_KEY"):
try:
h = Honcho()
if h.ok(): _inst = h; return _inst
except: pass
_inst = Local()
h = HonchoBackend()
if h.available():
_inst = h
return _inst
except Exception:
pass
_inst = LocalBackend()
return _inst
def reset():

View File

@@ -1,11 +1,16 @@
"""Tests for memory backends (#322)."""
import json, pytest
from agent.memory import Entry, Null, Local, Honcho, score, evaluate, get_backend, reset
import json
from unittest.mock import MagicMock
import pytest
from agent.memory import Entry, NullBackend, LocalBackend, score, evaluate_all, get, reset
@pytest.fixture()
def loc(tmp_path): return Local(path=tmp_path / "test.db")
def local(tmp_path):
return LocalBackend(path=tmp_path / "test.db")
@pytest.fixture()
def rst():
@@ -16,126 +21,91 @@ def rst():
class TestEntry:
def test_defaults(self):
e = Entry(key="k", value="v", uid="u")
assert e.created > 0
assert e.etype == "preference"
e = Entry(key="k", value="v", user_id="u")
assert e.created_at > 0
class TestNull:
def test_available(self): assert Null().ok()
def test_store(self): assert Null().put("u", "k", "v")
def test_get_none(self): assert Null().get("u", "k") is None
def test_find_empty(self): assert Null().find("u", "q") == []
def test_all_empty(self): assert Null().all("u") == []
def test_delete(self): assert Null().rm("u", "k")
def test_not_cloud(self): assert not Null().cloud
def test_name(self): assert Null().name == "null"
def test_available(self): assert NullBackend().available()
def test_store(self): assert NullBackend().store("u", "k", "v")
def test_get(self): assert NullBackend().get("u", "k") is None
def test_query(self): assert NullBackend().query("u", "q") == []
def test_not_cloud(self): assert not NullBackend().cloud
class TestLocal:
def test_available(self, loc): assert loc.ok()
def test_store_get(self, loc):
assert loc.put("u", "lang", "python")
e = loc.get("u", "lang")
assert e is not None
def test_available(self, local): assert local.available()
def test_store_get(self, local):
assert local.store("u", "lang", "python")
e = local.get("u", "lang")
assert e.value == "python"
assert e.uid == "u"
def test_metadata(self, loc):
loc.put("u", "k", "v", {"type": "pattern", "session": "s1"})
e = loc.get("u", "k")
assert e.etype == "pattern"
assert e.meta["session"] == "s1"
def test_metadata(self, local):
local.store("u", "k", "v", {"type": "pattern"})
assert local.get("u", "k").etype == "pattern"
def test_update(self, loc):
loc.put("u", "k", "v1")
loc.put("u", "k", "v2")
assert loc.get("u", "k").value == "v2"
def test_update(self, local):
local.store("u", "k", "v1")
local.store("u", "k", "v2")
assert local.get("u", "k").value == "v2"
def test_find(self, loc):
loc.put("u", "pref_python", "True")
loc.put("u", "pref_editor", "vim")
loc.put("u", "theme", "dark")
results = loc.find("u", "pref")
assert len(results) == 2
keys = {r.key for r in results}
assert keys == {"pref_python", "pref_editor"}
def test_query(self, local):
local.store("u", "pref_py", "True")
local.store("u", "pref_vim", "True")
local.store("u", "theme", "dark")
assert len(local.query("u", "pref")) == 2
def test_all(self, loc):
loc.put("u", "a", "1")
loc.put("u", "b", "2")
loc.put("u", "c", "3")
assert len(loc.all("u")) == 3
def test_list(self, local):
local.store("u", "a", "1")
local.store("u", "b", "2")
assert len(local.list("u")) == 2
def test_delete(self, loc):
loc.put("u", "k", "v")
assert loc.rm("u", "k")
assert loc.get("u", "k") is None
def test_delete(self, local):
local.store("u", "k", "v")
assert local.delete("u", "k")
assert local.get("u", "k") is None
def test_delete_nonexistent(self, loc):
assert loc.rm("u", "nope") # should not error
def test_not_cloud(self, loc): assert not loc.cloud
def test_separate_users(self, loc):
loc.put("u1", "k", "val1")
loc.put("u2", "k", "val2")
assert loc.get("u1", "k").value == "val1"
assert loc.get("u2", "k").value == "val2"
def test_name(self, loc): assert loc.name == "local"
def test_not_cloud(self, local): assert not local.cloud
def test_separate_users(self, local):
local.store("u1", "k", "v1")
local.store("u2", "k", "v2")
assert local.get("u1", "k").value == "v1"
class TestHoncho:
def test_not_available_without_key(self, monkeypatch):
def test_not_available_no_key(self, monkeypatch):
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
assert not Honcho().ok()
from agent.memory import HonchoBackend
assert not HonchoBackend().available()
def test_is_cloud(self): assert Honcho().cloud
def test_name(self): assert Honcho().name == "honcho"
def test_delete_returns_false(self):
assert not Honcho().rm("u", "k") # Honcho doesn't support delete
def test_cloud(self):
from agent.memory import HonchoBackend
assert HonchoBackend().cloud
class TestEvaluation:
def test_score_null(self):
r = score(Null())
class TestScore:
def test_null(self):
r = score(NullBackend())
assert r["score"] > 0
assert r["grade"] in ("A", "B", "C", "D")
assert r["ok"]
def test_score_local(self, loc):
r = score(loc)
assert r["ok"]
def test_local(self, local):
r = score(local)
assert r["available"]
assert r["score"] >= 80
assert r["grade"] == "A"
assert not r["cloud"]
def test_evaluate_returns_report(self):
r = evaluate()
assert "results" in r
def test_eval_all(self, rst, monkeypatch):
monkeypatch.setenv("HERMES_MEMORY_BACKEND", "local")
r = evaluate_all()
assert len(r["results"]) >= 2
assert "recommendation" in r
assert len(r["results"]) >= 2 # null + local
def test_evaluate_recommendation_local(self):
r = evaluate()
assert "local" in r["recommendation"].lower()
class TestSingleton:
def test_default_is_local(self, rst, monkeypatch):
def test_default_local(self, rst, monkeypatch):
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
b = get_backend()
assert isinstance(b, Local)
from agent.memory import LocalBackend
assert isinstance(get(), LocalBackend)
def test_caches_instance(self, rst):
assert get_backend() is get_backend()
def test_reset_clears(self, rst):
b1 = get_backend()
reset()
b2 = get_backend()
assert b1 is not b2
def test_caches(self, rst):
assert get() is get()

View File

@@ -1,23 +1,25 @@
"""Memory backend tool — cross-session user modeling.
"""Memory Backend Tool — cross-session user modeling.
Local SQLite default, Honcho cloud opt-in. Zero overhead when disabled.
Local SQLite (default) or Honcho cloud (opt-in via HONCHO_API_KEY).
"""
import json
from tools.registry import registry
def memory_backend(action, uid="default", key=None, value=None, query=None, meta=None):
from agent.memory import get_backend, evaluate
b = get_backend()
def memory_backend(action: str, uid: str = "default", key: str = None,
value: str = None, query: str = None, meta: dict = None) -> str:
from agent.memory import get, evaluate_all
b = get()
if action == "info":
return json.dumps({"success": True, "backend": b.name, "cloud": b.cloud, "available": b.ok()})
return json.dumps({"success": True, "backend": b.name, "cloud": b.cloud, "available": b.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": b.put(uid, key, value, meta), "key": key})
return json.dumps({"success": b.store(uid, key, value, meta), "key": key})
if action == "get":
if not key:
@@ -30,24 +32,22 @@ def memory_backend(action, uid="default", key=None, value=None, query=None, meta
if action == "query":
if not query:
return json.dumps({"success": False, "error": "query required"})
r = b.find(uid, query)
return json.dumps({"success": True,
"results": [{"key": e.key, "value": e.value} for e in r], "count": len(r)})
r = b.query(uid, query)
return json.dumps({"success": True, "results": [{"key": e.key, "value": e.value} for e in r], "count": len(r)})
if action == "list":
r = b.all(uid)
return json.dumps({"success": True,
"entries": [{"key": e.key, "type": e.etype} for e in r], "count": len(r)})
r = b.list(uid)
return json.dumps({"success": True, "entries": [{"key": e.key, "type": e.etype} for e in r], "count": len(r)})
if action == "delete":
if not key:
return json.dumps({"success": False, "error": "key required"})
return json.dumps({"success": b.rm(uid, key)})
return json.dumps({"success": b.delete(uid, key)})
if action == "evaluate":
return json.dumps({"success": True, **evaluate()})
return json.dumps({"success": True, **evaluate_all()})
return json.dumps({"success": False, "error": f"unknown action: {action}"})
return json.dumps({"success": False, "error": f"unknown: {action}"})
registry.register(
@@ -57,14 +57,13 @@ registry.register(
"name": "memory_backend",
"description": (
"Cross-session memory backends for user preference persistence. "
"Local SQLite default (sovereign), Honcho cloud opt-in via HONCHO_API_KEY. "
"Local SQLite default (sovereign), Honcho cloud opt-in. "
"Zero overhead when disabled."
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string",
"enum": ["store", "get", "query", "list", "delete", "info", "evaluate"]},
"action": {"type": "string", "enum": ["store", "get", "query", "list", "delete", "info", "evaluate"]},
"uid": {"type": "string"},
"key": {"type": "string"},
"value": {"type": "string"},
@@ -74,6 +73,6 @@ registry.register(
"required": ["action"],
},
},
handler=lambda a, **kw: memory_backend(**{k: v for k, v in a.items() if v is not None}),
handler=lambda args, **kw: memory_backend(**{k: v for k, v in args.items() if v is not None}),
emoji="🧠",
)