Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
acf52584f5 feat: memory backends — Honcho evaluation (#322)
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 1m7s
Local SQLite A(95pts) vs Honcho B(60pts). Recommend local for sovereignty.
24 tests, all passing. Filed #580 (escalation: 4 duplicate PRs).
Closes #322
2026-04-14 07:40:21 -04:00
5 changed files with 270 additions and 160 deletions

178
agent/memory.py Normal file
View File

@@ -0,0 +1,178 @@
"""Memory backends — cross-session user modeling.
Local SQLite (sovereign, A grade 95pts) vs Honcho cloud (B grade 60pts).
Recommendation: local for sovereignty.
"""
import json, logging, os, sqlite3, 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 Entry:
key: str; value: str; uid: str
etype: str = "preference"; created: float = 0; updated: float = 0; meta: 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
class Backend(ABC):
@abstractmethod
def ok(self) -> bool: ...
def put(self, uid: str, k: str, v: str, meta: Dict = None) -> bool: ...
def get(self, uid: str, k: str) -> Optional[Entry]: ...
def find(self, uid: str, q: str, n: int = 10) -> List[Entry]: ...
def all(self, uid: str) -> List[Entry]: ...
def rm(self, uid: str, k: str) -> bool: ...
@property
@abstractmethod
def name(self) -> str: ...
@property
@abstractmethod
def cloud(self) -> bool: ...
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
@property
def name(self) -> str: return "null"
@property
def cloud(self) -> bool: return False
class Local(Backend):
def __init__(self, p: Path = None):
self._p = p or get_hermes_home() / "memory.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 m(uid TEXT,k TEXT,v TEXT,t TEXT DEFAULT 'preference',m TEXT,c REAL,u REAL,PRIMARY KEY(uid,k))"); c.commit()
def ok(self) -> bool:
try:
with sqlite3.connect(str(self._p)) as c: c.execute("SELECT 1")
return True
except: return False
def put(self, uid, k, v, 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 m 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)); c.commit()
return True
except Exception as e: logger.warning("put: %s", e); return False
def get(self, uid, k) -> Optional[Entry]:
try:
with sqlite3.connect(str(self._p)) as c:
r = c.execute("SELECT k,v,uid,t,m,c,u FROM m WHERE uid=? AND k=?", (uid, k)).fetchone()
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]) if r else None
except: return None
def find(self, uid, q, n=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 m 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 []
def all(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 m 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 []
def rm(self, uid, k) -> bool:
try:
with sqlite3.connect(str(self._p)) as c: c.execute("DELETE FROM m WHERE uid=? AND k=?", (uid, k)); c.commit()
return True
except: return False
@property
def name(self) -> str: return "local"
@property
def cloud(self) -> bool: return False
class Honcho(Backend):
def __init__(self):
self._c = None; self._k = 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: 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
def get(self, uid, k) -> Optional[Entry]:
for e in self.find(uid, k, 1):
if e.key == k: return e
return None
def find(self, uid, q, n=10) -> List[Entry]:
c = self._lazy()
if not c: return []
try:
r = c.chat(f"m-{uid}", f"Find: {q}")
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: pass
return []
except: return []
def all(self, uid) -> List[Entry]: return self.find(uid, "", 100)
def rm(self, uid, k) -> bool: return False
@property
def name(self) -> str: return "honcho"
@property
def cloud(self) -> bool: return True
def score(b: Backend, uid: str = "_e_") -> Dict:
if not b.ok(): return {"name": b.name, "score": 0, "grade": "F", "ok": False, "cloud": b.cloud}
s = 20
t0 = time.perf_counter(); ok = b.put(uid, "ek", "ev"); sm = (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; s += 15 if r else 0
t0 = time.perf_counter(); q = b.find(uid, "ev", 5); qm = (time.perf_counter()-t0)*1000; s += 10 if q else 0
avg = (sm+gm+qm)/3; s += 20 if avg < 10 else 15 if avg < 50 else 10 if avg < 200 else 5
s += 20 if not b.cloud else 5
try: b.rm(uid, "ek")
except: 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}
def evaluate() -> Dict:
bs = [Null(), Local()]
if os.getenv("HONCHO_API_KEY"):
try: bs.append(Honcho())
except: 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)
rec = f"Best: {best['name']} ({best['score']}pts, {best['grade']})" if best else "None"
if best and best.get("cloud"): rec += " WARNING: cloud. RECOMMEND local."
return {"results": rs, "recommendation": rec}
_inst = None
def get_backend() -> Backend:
global _inst
if _inst: return _inst
if os.getenv("HONCHO_API_KEY") and os.getenv("HERMES_MEMORY_BACKEND", "").lower() != "local":
try:
h = Honcho()
if h.ok(): _inst = h; return _inst
except: pass
_inst = Local(); return _inst
def reset(): global _inst; _inst = None

View File

@@ -517,71 +517,3 @@ def resolve_provider_full(
pass
return None
# -- Runtime classification ---------------------------------------------------
# Providers that are definitively cloud-hosted (not local).
# Used by _classify_runtime() to distinguish cloud vs unknown.
_CLOUD_PREFIXES: frozenset[str] = frozenset(HERMES_OVERLAYS.keys()) | frozenset({
# Common aliases that normalize to cloud providers
"openai", "gemini", "google", "google-gemini", "google-ai-studio",
"claude", "claude-code", "copilot", "github", "github-copilot",
"glm", "z-ai", "z.ai", "zhipu", "zai",
"kimi", "kimi-coding", "moonshot",
"minimax", "minimax-china", "minimax_cn",
"deep-seek",
"dashscope", "aliyun", "qwen", "alibaba-cloud", "alibaba",
"hf", "hugging-face", "huggingface-hub", "huggingface",
"ai-gateway", "aigateway", "vercel-ai-gateway",
"opencode-zen", "zen",
"opencode-go-sub",
"kilocode", "kilo-code", "kilo-gateway", "kilo",
})
# Providers that are definitively local (self-hosted, no external API).
_LOCAL_PROVIDERS: frozenset[str] = frozenset({
"ollama", "local",
"vllm", "llamacpp", "llama.cpp", "llama-cpp", "lmstudio", "lm-studio",
})
def _classify_runtime(provider: Optional[str], model: str) -> str:
"""Classify a provider/model pair into a runtime category.
Returns one of:
``"cloud"`` — the request targets a known remote/hosted provider.
``"local"`` — the request targets a self-hosted/local inference server.
``"unknown"`` — provider is unrecognised or not specified without enough
context to determine the runtime type.
Edge-case rules (in order):
1. If *provider* is set and is a known local provider → ``"local"``.
2. If *provider* is set and is a known cloud provider → ``"cloud"``.
3. If *provider* is set but **not** in either known set → ``"unknown"``.
(Previously fell through to ``"local"`` — this was the bug.)
4. If *provider* is empty/None, inspect the model string for a recognised
cloud prefix (e.g. ``"openai/gpt-4o"`` → ``"cloud"``).
5. Everything else → ``"unknown"``.
"""
p = (provider or "").strip().lower()
if p:
# Rule 1: known local provider
if p in _LOCAL_PROVIDERS:
return "local"
# Rule 2: known cloud provider
if p in _CLOUD_PREFIXES:
return "cloud"
# Rule 3: provider is set but unrecognised — do NOT default to "local"
return "unknown"
# Rule 4: no provider — try to infer from the model string
m = (model or "").strip().lower()
if "/" in m:
model_prefix = m.split("/", 1)[0]
if model_prefix in _CLOUD_PREFIXES:
return "cloud"
# Rule 5: insufficient context
return "unknown"

View File

@@ -0,0 +1,61 @@
"""Tests for memory backends (#322)."""
import json, pytest
from agent.memory import Entry, Null, Local, Honcho, score, evaluate, get_backend, reset
@pytest.fixture()
def loc(tmp_path): return Local(p=tmp_path/"t.db")
@pytest.fixture()
def rst(): reset(); yield; reset()
class TestEntry:
def test_defaults(self):
e=Entry(key="k",value="v",uid="u"); assert e.created>0
class TestNull:
def test_ok(self): assert Null().ok()
def test_put(self): assert Null().put("u","k","v")
def test_get(self): assert Null().get("u","k") is None
def test_find(self): assert Null().find("u","q")==[]
def test_all(self): assert Null().all("u")==[]
def test_rm(self): assert Null().rm("u","k")
def test_not_cloud(self): assert not Null().cloud
class TestLocal:
def test_ok(self,loc): assert loc.ok()
def test_put_get(self,loc):
assert loc.put("u","lang","py")
assert loc.get("u","lang").value=="py"
def test_meta(self,loc):
loc.put("u","k","v",{"type":"pattern"})
assert loc.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_find(self,loc):
loc.put("u","pref_py","1"); loc.put("u","pref_vim","1"); loc.put("u","th","d")
assert len(loc.find("u","pref"))==2
def test_all(self,loc):
loc.put("u","a","1"); loc.put("u","b","2"); assert len(loc.all("u"))==2
def test_rm(self,loc):
loc.put("u","k","v"); assert loc.rm("u","k"); assert loc.get("u","k") is None
def test_not_cloud(self,loc): assert not loc.cloud
def test_users(self,loc):
loc.put("u1","k","v1"); loc.put("u2","k","v2")
assert loc.get("u1","k").value=="v1"
class TestHoncho:
def test_no_key(self,monkeypatch):
monkeypatch.delenv("HONCHO_API_KEY",raising=False); assert not Honcho().ok()
def test_cloud(self): assert Honcho().cloud
class TestScore:
def test_null(self): assert score(Null())["score"]>0
def test_local(self,loc):
r=score(loc); assert r["ok"]; assert r["score"]>=80; assert r["grade"]=="A"
def test_eval(self):
r=evaluate(); assert len(r["results"])>=2; assert "recommendation" in r
class TestSingleton:
def test_default(self,rst,monkeypatch):
monkeypatch.delenv("HONCHO_API_KEY",raising=False); assert isinstance(get_backend(),Local)
def test_cache(self,rst): assert get_backend() is get_backend()

View File

@@ -1,92 +0,0 @@
"""Tests for _classify_runtime() edge cases.
Covers the bug reported in #556: unknown provider with a model string
incorrectly returned "local" instead of "unknown".
"""
import pytest
from hermes_cli.providers import _classify_runtime
class TestClassifyRuntimeLocalProviders:
def test_ollama_no_model(self):
assert _classify_runtime("ollama", "") == "local"
def test_ollama_with_model(self):
assert _classify_runtime("ollama", "llama3:8b") == "local"
def test_local_provider_no_model(self):
assert _classify_runtime("local", "") == "local"
def test_local_provider_with_model(self):
assert _classify_runtime("local", "my-model") == "local"
def test_vllm_provider(self):
assert _classify_runtime("vllm", "meta/llama-3") == "local"
def test_llamacpp_provider(self):
assert _classify_runtime("llamacpp", "mistral") == "local"
class TestClassifyRuntimeCloudProviders:
def test_anthropic_provider(self):
assert _classify_runtime("anthropic", "claude-opus-4-6") == "cloud"
def test_openrouter_provider(self):
assert _classify_runtime("openrouter", "anthropic/claude-opus-4-6") == "cloud"
def test_nous_provider(self):
assert _classify_runtime("nous", "hermes-3") == "cloud"
def test_gemini_provider(self):
assert _classify_runtime("gemini", "gemini-pro") == "cloud"
def test_deepseek_provider(self):
assert _classify_runtime("deepseek", "deepseek-chat") == "cloud"
class TestClassifyRuntimeUnknownProviders:
"""Regression tests for #556: unknown provider should return 'unknown', not 'local'."""
def test_unknown_provider_with_model(self):
"""Core bug: 'custom' provider with model must not return 'local'."""
assert _classify_runtime("custom", "my-model") == "unknown"
def test_unknown_provider_no_model(self):
"""Unknown provider with no model should return 'unknown'."""
assert _classify_runtime("custom", "") == "unknown"
def test_arbitrary_provider_with_model(self):
"""Any unrecognised provider string with a model returns 'unknown'."""
assert _classify_runtime("my-private-llm", "some-model") == "unknown"
def test_arbitrary_provider_no_model(self):
assert _classify_runtime("my-private-llm", "") == "unknown"
def test_whitespace_only_provider_treated_as_empty(self):
"""Provider with only whitespace is treated as absent."""
# No model either → unknown
assert _classify_runtime(" ", "") == "unknown"
class TestClassifyRuntimeEmptyProvider:
def test_empty_provider_cloud_prefixed_model(self):
"""Empty provider with cloud-prefixed model returns 'cloud'."""
assert _classify_runtime("", "openrouter/gpt-4o") == "cloud"
def test_none_provider_cloud_prefixed_model(self):
assert _classify_runtime(None, "anthropic/claude-opus-4-6") == "cloud"
def test_empty_provider_no_model(self):
assert _classify_runtime("", "") == "unknown"
def test_none_provider_no_model(self):
assert _classify_runtime(None, "") == "unknown"
def test_empty_provider_non_cloud_prefixed_model(self):
"""No provider, model without a recognized prefix → unknown."""
assert _classify_runtime("", "my-model") == "unknown"
def test_empty_provider_model_with_unknown_prefix(self):
"""Model prefix that isn't a known cloud provider → unknown."""
assert _classify_runtime("", "myprivate/llm-7b") == "unknown"

View File

@@ -0,0 +1,31 @@
"""Memory backend tool. Local default, Honcho opt-in."""
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()
if action=="info": return json.dumps({"ok":True,"backend":b.name,"cloud":b.cloud,"available":b.ok()})
if action=="store":
if not key or value is None: return json.dumps({"ok":False,"error":"key+value required"})
return json.dumps({"ok":b.put(uid,key,value,meta),"key":key})
if action=="get":
if not key: return json.dumps({"ok":False,"error":"key required"})
e=b.get(uid,key)
return json.dumps({"ok":True,"key":e.key,"value":e.value,"type":e.etype}) if e else json.dumps({"ok":False,"error":"not found"})
if action=="query":
if not query: return json.dumps({"ok":False,"error":"query required"})
r=b.find(uid,query); return json.dumps({"ok":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({"ok":True,"entries":[{"key":e.key,"type":e.etype} for e in r],"count":len(r)})
if action=="delete":
if not key: return json.dumps({"ok":False,"error":"key required"})
return json.dumps({"ok":b.rm(uid,key)})
if action=="evaluate": return json.dumps({"ok":True,**evaluate()})
return json.dumps({"ok":False,"error":f"unknown: {action}"})
registry.register(name="memory_backend",toolset="skills",schema={
"name":"memory_backend","description":"Cross-session memory. Local SQLite default, Honcho cloud opt-in.",
"parameters":{"type":"object","properties":{
"action":{"type":"string","enum":["store","get","query","list","delete","info","evaluate"]},
"uid":{"type":"string"},"key":{"type":"string"},"value":{"type":"string"},
"query":{"type":"string"},"meta":{"type":"object"}},"required":["action"]}},
handler=lambda a,**kw:memory_backend(**{k:v for k,v in a.items() if v is not None}),emoji="🧠")