Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""Tests for the ByteRover memory provider config gates."""
|
||||
|
||||
from plugins.memory.byterover import ByteRoverMemoryProvider
|
||||
|
||||
|
||||
def test_auto_extract_false_skips_sync_turn(monkeypatch):
|
||||
calls = []
|
||||
provider = ByteRoverMemoryProvider({"auto_extract": False})
|
||||
provider.initialize("session-1")
|
||||
|
||||
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
|
||||
|
||||
provider.sync_turn("please remember this detail", "acknowledged")
|
||||
|
||||
assert calls == []
|
||||
assert provider._sync_thread is None
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for config-schema loading from memory provider plugin dirs."""
|
||||
|
||||
import plugins.memory.config_schema as config_schema
|
||||
from plugins.memory.config_schema import get_provider_config_schema
|
||||
|
||||
|
||||
def test_unknown_provider_is_none():
|
||||
assert get_provider_config_schema("builtin") is None
|
||||
|
||||
|
||||
def test_plugin_without_schema_is_none():
|
||||
# mem0 is a real plugin dir that declares no config_schema.py.
|
||||
assert get_provider_config_schema("mem0") is None
|
||||
|
||||
|
||||
def test_schemas_are_cached_per_provider():
|
||||
assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho")
|
||||
|
||||
|
||||
def test_cache_keys_on_schema_path_not_name(monkeypatch, tmp_path):
|
||||
# User-installed plugins are per-profile; two profiles' plugins sharing a
|
||||
# name must not answer for each other.
|
||||
import plugins.memory as memory
|
||||
|
||||
schemas = {}
|
||||
for label in ("A", "B"):
|
||||
plugin_dir = tmp_path / label / "custom"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "config_schema.py").write_text(
|
||||
"from plugins.memory.config_schema import ProviderConfigSchema\n"
|
||||
f'CONFIG_SCHEMA = ProviderConfigSchema(name="custom", label="{label}")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
schemas[label] = plugin_dir
|
||||
|
||||
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
|
||||
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["A"])
|
||||
assert get_provider_config_schema("custom").label == "A"
|
||||
|
||||
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["B"])
|
||||
assert get_provider_config_schema("custom").label == "B"
|
||||
|
||||
|
||||
def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
|
||||
# A load failure must retry on the next request, not pin an empty panel.
|
||||
broken_dir = tmp_path / "broken"
|
||||
broken_dir.mkdir()
|
||||
schema_file = broken_dir / "config_schema.py"
|
||||
schema_file.write_text("this is not python(", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
|
||||
import plugins.memory as memory
|
||||
|
||||
monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir)
|
||||
|
||||
assert get_provider_config_schema("broken") is None
|
||||
assert not config_schema._SCHEMA_CACHE
|
||||
|
||||
schema_file.write_text(
|
||||
"from plugins.memory.config_schema import ProviderConfigSchema\n"
|
||||
'CONFIG_SCHEMA = ProviderConfigSchema(name="broken", label="Broken")\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
recovered = get_provider_config_schema("broken")
|
||||
assert recovered is not None
|
||||
assert recovered.label == "Broken"
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Discovery parity for out-of-tree memory providers.
|
||||
|
||||
Upstream policy closed ``plugins/memory/`` to new providers, so every new
|
||||
memory backend now lives outside this tree. These tests cover the two sources
|
||||
that reach it — project-local directories and pip entry points — and the
|
||||
integration points a directory install gets for free but a pip install
|
||||
historically did not: the dashboard config panel, the provider's CLI
|
||||
subcommands, and the ``memory.provider`` dropdown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.memory as memory_plugins
|
||||
|
||||
PROVIDER_SOURCE = """\
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
|
||||
class Provider(MemoryProvider):
|
||||
@property
|
||||
def name(self):
|
||||
return "{name}"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
def initialize(self, *a, **kw):
|
||||
pass
|
||||
|
||||
def get_tool_schemas(self):
|
||||
return []
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_memory_provider(Provider())
|
||||
"""
|
||||
|
||||
|
||||
class FakeEntryPoint:
|
||||
"""Mirrors the importlib.metadata EntryPoint surface discovery uses."""
|
||||
|
||||
group = "hermes_agent.memory_providers"
|
||||
|
||||
def __init__(self, name, value):
|
||||
self.name = name
|
||||
self.value = value
|
||||
|
||||
def load(self):
|
||||
import importlib
|
||||
|
||||
module_name, _, attr = self.value.partition(":")
|
||||
module = importlib.import_module(module_name)
|
||||
return getattr(module, attr) if attr else module
|
||||
|
||||
|
||||
class FakeEntryPoints(list):
|
||||
def select(self, *, group):
|
||||
return [ep for ep in self if ep.group == group]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entry_points(monkeypatch):
|
||||
"""Install a replaceable entry-point set for the memory group."""
|
||||
registry = FakeEntryPoints()
|
||||
monkeypatch.setattr(importlib.metadata, "entry_points", lambda: registry)
|
||||
return registry
|
||||
|
||||
|
||||
def _write_provider_dir(root: Path, name: str) -> Path:
|
||||
provider = root / name
|
||||
provider.mkdir(parents=True)
|
||||
(provider / "__init__.py").write_text(PROVIDER_SOURCE.format(name=name), encoding="utf-8")
|
||||
return provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project-local providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_project_dir_is_ignored_without_opt_in(tmp_path, monkeypatch):
|
||||
"""A repo you merely cd into must not be able to offer a memory backend."""
|
||||
_write_provider_dir(tmp_path / ".hermes" / "plugins", "projectmem")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False)
|
||||
|
||||
assert "projectmem" not in memory_plugins.list_memory_provider_names()
|
||||
assert memory_plugins.find_provider_dir("projectmem") is None
|
||||
|
||||
|
||||
def test_project_dir_is_discovered_when_opted_in(tmp_path, monkeypatch):
|
||||
provider = _write_provider_dir(tmp_path / ".hermes" / "plugins", "projectmem")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
|
||||
|
||||
assert "projectmem" in memory_plugins.list_memory_provider_names()
|
||||
assert memory_plugins.find_provider_dir("projectmem") == provider
|
||||
|
||||
|
||||
def test_bundled_still_wins_over_project(tmp_path, monkeypatch):
|
||||
"""Precedence here is bundled-first, the reverse of the general
|
||||
PluginManager's later-wins order. A provider is activated by name, so a
|
||||
directory dropped into the working tree must not be able to shadow a
|
||||
shipped one and silently redirect the agent's memory."""
|
||||
_write_provider_dir(tmp_path / ".hermes" / "plugins", "honcho")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
|
||||
|
||||
resolved = memory_plugins.find_provider_dir("honcho")
|
||||
assert resolved == Path(memory_plugins.__file__).parent / "honcho"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pip entry-point providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_entry_point_provider_is_listed(entry_points, tmp_path, monkeypatch):
|
||||
"""list_memory_provider_names() fills the dashboard's memory.provider
|
||||
dropdown. Enumerating entry points reads distribution metadata without
|
||||
executing any of it, so this stays safe to call at import time."""
|
||||
entry_points.append(FakeEntryPoint("pipmem", "pipmem_pkg"))
|
||||
assert "pipmem" in memory_plugins.list_memory_provider_names()
|
||||
|
||||
|
||||
def test_find_provider_dir_resolves_a_package_entry_point(entry_points, tmp_path, monkeypatch):
|
||||
"""Without a directory, a pip-installed provider silently loses its
|
||||
dashboard config panel and its `hermes <provider>` subcommands — both are
|
||||
read from disk rather than imported."""
|
||||
package = tmp_path / "pipmem_pkg"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text(PROVIDER_SOURCE.format(name="pipmem"), encoding="utf-8")
|
||||
(package / "config_schema.py").write_text("CONFIG_SCHEMA = None\n", encoding="utf-8")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
entry_points.append(FakeEntryPoint("pipmem", "pipmem_pkg:register"))
|
||||
|
||||
assert memory_plugins.find_provider_dir("pipmem") == package
|
||||
|
||||
|
||||
def test_resolving_an_entry_point_does_not_import_it(entry_points, tmp_path, monkeypatch):
|
||||
"""Discovery runs before the operator has chosen a provider. Importing
|
||||
every installed candidate would execute third-party code on the strength of
|
||||
a package merely being present."""
|
||||
package = tmp_path / "sideeffect_pkg"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text(
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
import pathlib
|
||||
pathlib.Path(__file__).with_name("IMPORTED").write_text("x")
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
entry_points.append(FakeEntryPoint("sideeffect", "sideeffect_pkg"))
|
||||
|
||||
assert memory_plugins.find_provider_dir("sideeffect") == package
|
||||
assert not (package / "IMPORTED").exists()
|
||||
assert "sideeffect_pkg" not in sys.modules
|
||||
|
||||
|
||||
def test_bare_module_entry_point_has_no_directory(entry_points, tmp_path, monkeypatch):
|
||||
"""A single-file provider has nowhere to put a sibling config_schema.py, so
|
||||
it resolves to None rather than handing back the whole site-packages root."""
|
||||
(tmp_path / "flatmem.py").write_text(PROVIDER_SOURCE.format(name="flatmem"), encoding="utf-8")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
entry_points.append(FakeEntryPoint("flatmem", "flatmem"))
|
||||
|
||||
assert memory_plugins.find_provider_dir("flatmem") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_secondary_registration_cannot_cost_the_provider(tmp_path, monkeypatch):
|
||||
"""register_auxiliary_task used to raise AttributeError on the collector —
|
||||
which the loader caught, discarded the registered provider, and replaced
|
||||
with a bare second instance built by the subclass scan. A silent downgrade
|
||||
that looked like success."""
|
||||
plugins_root = tmp_path / "plugins"
|
||||
provider = _write_provider_dir(plugins_root, "auxmem")
|
||||
(provider / "__init__.py").write_text(
|
||||
PROVIDER_SOURCE.format(name="auxmem").replace(
|
||||
" ctx.register_memory_provider(Provider())\n",
|
||||
" instance = Provider()\n"
|
||||
" instance.marked = True\n"
|
||||
" ctx.register_memory_provider(instance)\n"
|
||||
" ctx.register_auxiliary_task(\n"
|
||||
" 'auxmem_filter', display_name='Aux', description='d'\n"
|
||||
" )\n",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
loaded = memory_plugins.load_memory_provider("auxmem")
|
||||
assert loaded is not None
|
||||
assert loaded.name == "auxmem"
|
||||
# The instance register() handed over, not a replacement.
|
||||
assert getattr(loaded, "marked", False)
|
||||
|
||||
|
||||
def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch):
|
||||
"""Memory providers are activated by naming them in memory.provider. Using
|
||||
a real PluginContext for secondary registrations must not start also
|
||||
requiring the plugin in plugins.enabled — that would break every existing
|
||||
user-installed provider."""
|
||||
_write_provider_dir(tmp_path / "plugins", "gatedmem")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
assert memory_plugins.load_memory_provider("gatedmem") is not None
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for Hindsight's declared config surface."""
|
||||
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
get_provider_config_schema,
|
||||
)
|
||||
|
||||
|
||||
def test_hindsight_is_declared():
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
|
||||
assert provider is not None
|
||||
assert provider.label == "Hindsight"
|
||||
assert {field.key for field in provider.fields} == {
|
||||
"mode",
|
||||
"api_key",
|
||||
"api_url",
|
||||
"bank_id",
|
||||
"recall_budget",
|
||||
}
|
||||
|
||||
|
||||
def test_fields_are_all_inline():
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
assert provider is not None
|
||||
|
||||
# Hindsight is simple enough to render fully in the compact panel, so it
|
||||
# never grows a Full config… modal.
|
||||
assert all(field.inline for field in provider.fields)
|
||||
|
||||
|
||||
def test_mode_gating_is_expressed_as_select_options():
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
assert provider is not None
|
||||
|
||||
mode = next(field for field in provider.fields if field.key == "mode")
|
||||
assert mode.kind == KIND_SELECT
|
||||
assert mode.allowed_values() == {"cloud", "local_external"}
|
||||
# local_embedded is intentionally unsupported on desktop.
|
||||
assert "local_embedded" not in mode.allowed_values()
|
||||
|
||||
|
||||
def test_api_key_is_a_secret_bound_to_env():
|
||||
provider = get_provider_config_schema("hindsight")
|
||||
assert provider is not None
|
||||
|
||||
api_key = next(field for field in provider.fields if field.key == "api_key")
|
||||
assert api_key.kind == KIND_SECRET
|
||||
assert api_key.is_secret is True
|
||||
assert api_key.env_key == "HINDSIGHT_API_KEY"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Regression tests: the embedded Hindsight profile env file carries the
|
||||
plaintext ``HINDSIGHT_API_LLM_API_KEY`` and must be created/kept owner-only
|
||||
(0600), and must not survive a failed post-write permission validation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.hindsight import (
|
||||
_embedded_profile_env_path,
|
||||
_materialize_embedded_profile_env,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_home(tmp_path, monkeypatch):
|
||||
isolated_home = tmp_path / "user-home"
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: isolated_home))
|
||||
return isolated_home
|
||||
|
||||
|
||||
_CONFIG = {
|
||||
"profile": "hermes",
|
||||
"llm_provider": "openai",
|
||||
"llm_model": "gpt-4o-mini",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
|
||||
def test_fresh_profile_env_is_owner_only_despite_permissive_umask():
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
profile_env = _materialize_embedded_profile_env(
|
||||
_CONFIG, llm_api_key="sk-hindsight-secret"
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert profile_env.exists()
|
||||
assert stat.S_IMODE(profile_env.stat().st_mode) == 0o600
|
||||
assert "HINDSIGHT_API_LLM_API_KEY=sk-hindsight-secret\n" in profile_env.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
|
||||
def test_rewrite_tightens_existing_world_readable_profile_env():
|
||||
profile_env = _embedded_profile_env_path(_CONFIG)
|
||||
profile_env.parent.mkdir(parents=True)
|
||||
profile_env.write_text("HINDSIGHT_API_LLM_API_KEY=stale\n", encoding="utf-8")
|
||||
os.chmod(profile_env, 0o644)
|
||||
|
||||
_materialize_embedded_profile_env(_CONFIG, llm_api_key="sk-current")
|
||||
|
||||
assert stat.S_IMODE(profile_env.stat().st_mode) == 0o600
|
||||
assert "HINDSIGHT_API_LLM_API_KEY=sk-current\n" in profile_env.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
|
||||
def test_secret_file_removed_when_permission_validation_fails(monkeypatch):
|
||||
"""If the post-write permission check cannot verify 0600, the plaintext
|
||||
key file must not be left behind."""
|
||||
import plugins.memory.hindsight as hs
|
||||
|
||||
def _fail_validation(profile_env):
|
||||
raise PermissionError(f"not owner-only: {profile_env}")
|
||||
|
||||
monkeypatch.setattr(hs, "_validate_profile_env_permissions", _fail_validation)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
_materialize_embedded_profile_env(_CONFIG, llm_api_key="sk-doomed")
|
||||
|
||||
assert not _embedded_profile_env_path(_CONFIG).exists(), (
|
||||
"secret env file must be cleaned up when validation fails"
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""NousResearch/hermes-agent#7718 — actionable message when local_embedded
|
||||
runtime (`hindsight-all`) is missing.
|
||||
|
||||
`local_embedded` imports `from hindsight import HindsightEmbedded`, provided
|
||||
only by `hindsight-all`. When it's absent the provider disables itself; the
|
||||
disable warning should point the user at the fix rather than just echoing
|
||||
`No module named 'hindsight'`.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import plugins.memory.hindsight as hs
|
||||
from plugins.memory.hindsight import HindsightMemoryProvider, _local_runtime_hint
|
||||
|
||||
|
||||
def test_hint_for_missing_hindsight_all():
|
||||
hint = _local_runtime_hint("No module named 'hindsight'")
|
||||
assert "hindsight-all" in hint
|
||||
assert "hermes memory setup" in hint
|
||||
assert sys.executable in hint
|
||||
|
||||
|
||||
def test_hint_for_missing_hindsight_embed():
|
||||
hint = _local_runtime_hint("No module named 'hindsight_embed.daemon_embed_manager'")
|
||||
assert "hindsight-all" in hint
|
||||
|
||||
|
||||
def test_no_hint_for_unrelated_runtime_error():
|
||||
# e.g. the NumPy-on-old-CPU failure _check_local_runtime also guards against
|
||||
assert _local_runtime_hint("Illegal instruction (NumPy SIMD)") == ""
|
||||
assert _local_runtime_hint(None) == ""
|
||||
|
||||
|
||||
# unavailable_reason() — surfaces the hint through the reachable path (#7718):
|
||||
# is_available() gates initialize() out, so the hint must come from here.
|
||||
|
||||
|
||||
def test_unavailable_reason_surfaces_hint_for_local_embedded(monkeypatch):
|
||||
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "local_embedded"})
|
||||
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (False, "No module named 'hindsight'"))
|
||||
reason = HindsightMemoryProvider().unavailable_reason()
|
||||
assert "hindsight-all" in reason
|
||||
assert reason == reason.strip() # no leading/trailing whitespace
|
||||
|
||||
|
||||
def test_unavailable_reason_empty_for_cloud(monkeypatch):
|
||||
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "cloud"})
|
||||
# Should not even probe the runtime for a cloud provider.
|
||||
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (_ for _ in ()).throw(AssertionError("probed")))
|
||||
assert HindsightMemoryProvider().unavailable_reason() == ""
|
||||
|
||||
|
||||
def test_unavailable_reason_empty_when_runtime_present(monkeypatch):
|
||||
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "local_embedded"})
|
||||
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (True, None))
|
||||
assert HindsightMemoryProvider().unavailable_reason() == ""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
"""Tests for the Hindsight setup-wizard starter-template step."""
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.hindsight import templates as tpl
|
||||
|
||||
|
||||
_CATALOG = {
|
||||
"templates": [
|
||||
{"id": "conversation", "name": "Conversation", "integrations": ["litellm", "hermes"],
|
||||
"manifest_file": "templates/conversation.json"},
|
||||
{"id": "coding-agent", "name": "Coding Agent", "integrations": ["claude-code"],
|
||||
"manifest_file": "templates/coding-agent.json"},
|
||||
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "integrations": ["hermes"],
|
||||
"manifest_file": "templates/hermes-gateway-bot.json"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_fetch_hermes_templates_filters_to_hermes(monkeypatch):
|
||||
monkeypatch.setattr(tpl, "_get_json", lambda url: _CATALOG)
|
||||
entries = tpl.fetch_hermes_templates("https://example/templates.json")
|
||||
ids = [e["id"] for e in entries]
|
||||
assert ids == ["conversation", "hermes-gateway-bot"] # coding-agent excluded
|
||||
|
||||
|
||||
def test_fetch_manifest_resolves_relative_url(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def _fake(url):
|
||||
seen["url"] = url
|
||||
return {"version": "1"}
|
||||
|
||||
monkeypatch.setattr(tpl, "_get_json", _fake)
|
||||
tpl.fetch_manifest(
|
||||
{"manifest_file": "templates/hermes-gateway-bot.json"},
|
||||
"https://raw.example/data/templates.json",
|
||||
)
|
||||
assert seen["url"] == "https://raw.example/data/templates/hermes-gateway-bot.json"
|
||||
|
||||
|
||||
def test_apply_template_posts_to_import_endpoint(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
@contextmanager
|
||||
def _fake_open(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
captured["method"] = req.get_method()
|
||||
captured["auth"] = req.get_header("Authorization")
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
|
||||
class _Resp:
|
||||
def read(self):
|
||||
return b""
|
||||
|
||||
yield _Resp()
|
||||
|
||||
monkeypatch.setattr(tpl, "open_credentialed_url", _fake_open)
|
||||
tpl.apply_template("https://api.hindsight.vectorize.io/", "hermes", "hsk_abc", {"version": "1"})
|
||||
|
||||
assert captured["url"] == "https://api.hindsight.vectorize.io/v1/default/banks/hermes/import"
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["auth"] == "Bearer hsk_abc"
|
||||
assert captured["body"] == {"version": "1"}
|
||||
|
||||
|
||||
def test_apply_template_omits_auth_when_no_key(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
@contextmanager
|
||||
def _fake_open(req, timeout=None):
|
||||
captured["auth"] = req.get_header("Authorization")
|
||||
|
||||
class _Resp:
|
||||
def read(self):
|
||||
return b""
|
||||
|
||||
yield _Resp()
|
||||
|
||||
monkeypatch.setattr(tpl, "open_credentialed_url", _fake_open)
|
||||
tpl.apply_template("http://localhost:8888", "hermes", None, {"version": "1"})
|
||||
assert captured["auth"] is None
|
||||
|
||||
|
||||
def _select_returning(idx):
|
||||
def _select(title, items, default=0, cancel_returns=None):
|
||||
return idx
|
||||
return _select
|
||||
|
||||
|
||||
def _select_seq(*returns):
|
||||
it = iter(returns)
|
||||
|
||||
def _select(title, items, default=0, cancel_returns=None):
|
||||
return next(it)
|
||||
|
||||
return _select
|
||||
|
||||
|
||||
def test_supported_for_mode():
|
||||
assert tpl.supported_for_mode("cloud") is True
|
||||
assert tpl.supported_for_mode("local_external") is True
|
||||
assert tpl.supported_for_mode("local_embedded") is False
|
||||
assert tpl.supported_for_mode("local") is False
|
||||
|
||||
|
||||
def test_run_template_step_applies_selected(monkeypatch):
|
||||
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
|
||||
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
|
||||
])
|
||||
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
|
||||
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: False)
|
||||
applied = {}
|
||||
monkeypatch.setattr(tpl, "apply_template",
|
||||
lambda api_url, bank_id, api_key, manifest: applied.update(bank=bank_id))
|
||||
|
||||
result = tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key="k",
|
||||
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
|
||||
)
|
||||
assert result == "hermes-gateway-bot"
|
||||
assert applied["bank"] == "hermes"
|
||||
|
||||
|
||||
def test_run_template_step_blank_selection_skips(monkeypatch):
|
||||
entries = [{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"}]
|
||||
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: entries)
|
||||
called = {"applied": False}
|
||||
monkeypatch.setattr(tpl, "apply_template",
|
||||
lambda *a, **k: called.update(applied=True))
|
||||
# index len(entries) == the "Blank" row
|
||||
result = tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key="k",
|
||||
select=_select_returning(len(entries)), cancelled=-1, log=lambda *_: None,
|
||||
)
|
||||
assert result is None
|
||||
assert called["applied"] is False
|
||||
|
||||
|
||||
def test_run_template_step_no_templates_is_noop(monkeypatch):
|
||||
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [])
|
||||
result = tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key="k",
|
||||
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_run_template_step_swallows_fetch_errors(monkeypatch):
|
||||
def _boom(url=None):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
monkeypatch.setattr(tpl, "fetch_hermes_templates", _boom)
|
||||
# must not raise
|
||||
assert tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key="k",
|
||||
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
|
||||
) is None
|
||||
|
||||
|
||||
def test_run_template_step_swallows_apply_errors(monkeypatch):
|
||||
# gap 1: a failed apply (e.g. 401 for an OAuth-only user) must not crash setup.
|
||||
import urllib.error
|
||||
|
||||
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
|
||||
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
|
||||
])
|
||||
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
|
||||
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: False)
|
||||
|
||||
def _raise(*a, **k):
|
||||
raise urllib.error.HTTPError("u", 401, "Unauthorized", {}, None)
|
||||
|
||||
monkeypatch.setattr(tpl, "apply_template", _raise)
|
||||
logs = []
|
||||
result = tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key=None,
|
||||
select=_select_returning(0), cancelled=-1, log=logs.append,
|
||||
)
|
||||
assert result is None
|
||||
assert any("Could not apply" in line for line in logs)
|
||||
|
||||
|
||||
def _fake_open(payload):
|
||||
@contextmanager
|
||||
def _cm(req, timeout=None):
|
||||
class _Resp:
|
||||
def read(self):
|
||||
return json.dumps(payload).encode("utf-8")
|
||||
|
||||
yield _Resp()
|
||||
|
||||
return _cm
|
||||
|
||||
|
||||
def test_probe_existing_customization_true_when_bank_has_config(monkeypatch):
|
||||
monkeypatch.setattr(tpl, "open_credentialed_url",
|
||||
_fake_open({"version": "1", "bank": {"reflect_mission": "x"}}))
|
||||
assert tpl.probe_existing_customization("https://api", "hermes", "k") is True
|
||||
|
||||
|
||||
def test_probe_existing_customization_false_when_empty(monkeypatch):
|
||||
monkeypatch.setattr(tpl, "open_credentialed_url",
|
||||
_fake_open({"version": "1"}))
|
||||
assert tpl.probe_existing_customization("https://api", "hermes", "k") is False
|
||||
|
||||
|
||||
def test_probe_existing_customization_false_on_error(monkeypatch):
|
||||
def _boom(req, timeout=None):
|
||||
raise OSError("no bank")
|
||||
|
||||
monkeypatch.setattr(tpl, "open_credentialed_url", _boom)
|
||||
assert tpl.probe_existing_customization("https://api", "missing", None) is False
|
||||
|
||||
|
||||
def _wire_apply(monkeypatch, customized):
|
||||
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
|
||||
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
|
||||
])
|
||||
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
|
||||
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: customized)
|
||||
called = {"applied": False}
|
||||
monkeypatch.setattr(tpl, "apply_template", lambda *a, **k: called.update(applied=True))
|
||||
return called
|
||||
|
||||
|
||||
def test_warns_and_keeps_existing_when_declined(monkeypatch):
|
||||
called = _wire_apply(monkeypatch, customized=True)
|
||||
# first select = pick template (0); second select = confirm -> "Keep existing" (1)
|
||||
result = tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key="k",
|
||||
select=_select_seq(0, 1), cancelled=-1, log=lambda *_: None,
|
||||
)
|
||||
assert result is None
|
||||
assert called["applied"] is False
|
||||
|
||||
|
||||
def test_warns_then_applies_when_confirmed(monkeypatch):
|
||||
called = _wire_apply(monkeypatch, customized=True)
|
||||
# pick template (0), confirm "Apply" (0)
|
||||
result = tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key="k",
|
||||
select=_select_seq(0, 0), cancelled=-1, log=lambda *_: None,
|
||||
)
|
||||
assert result == "hermes-gateway-bot"
|
||||
assert called["applied"] is True
|
||||
|
||||
|
||||
def test_fresh_bank_skips_the_warning(monkeypatch):
|
||||
called = _wire_apply(monkeypatch, customized=False)
|
||||
# only ONE select call (no confirm) — _select_seq with a single value proves it
|
||||
result = tpl.run_template_step(
|
||||
api_url="https://api", bank_id="hermes", api_key="k",
|
||||
select=_select_seq(0), cancelled=-1, log=lambda *_: None,
|
||||
)
|
||||
assert result == "hermes-gateway-bot"
|
||||
assert called["applied"] is True
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Regression tests for #57682 — holographic auto_extract harvested
|
||||
context-compaction handoff summaries into fact_store, and ran even when
|
||||
configured off.
|
||||
|
||||
Two compounding defects:
|
||||
|
||||
1. Gate: the plugin's config schema declares ``auto_extract`` as a string enum
|
||||
(``"false"``/``"true"``), and the ``on_session_end`` gate used plain
|
||||
truthiness — ``not "false"`` is ``False`` — so extraction ran despite being
|
||||
explicitly configured off.
|
||||
|
||||
2. Eligibility: ``_auto_extract_facts`` scanned every ``role == "user"``
|
||||
message. Compaction handoff summaries can be inserted as ``role="user"``
|
||||
messages, and their prose reliably matches the decision patterns
|
||||
(``we decided/agreed/chose``, ``the project uses/needs/requires``), so the
|
||||
compactor's own output was stored as a durable ``project`` fact on every
|
||||
session rollover that followed a compaction.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.context_compressor import (
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
SUMMARY_PREFIX,
|
||||
_MERGED_PRIOR_CONTEXT_HEADER,
|
||||
_MERGED_SUMMARY_DELIMITER,
|
||||
is_compaction_summary_message,
|
||||
)
|
||||
from plugins.memory.holographic import HolographicMemoryProvider
|
||||
|
||||
|
||||
def _make_provider(tmp_path, **config):
|
||||
base = {"db_path": str(tmp_path / "memory_store.db"), "hrr_dim": 64}
|
||||
base.update(config)
|
||||
provider = HolographicMemoryProvider(config=base)
|
||||
provider.initialize(session_id="test-session")
|
||||
return provider
|
||||
|
||||
|
||||
def _fact_contents(provider):
|
||||
return [f["content"] for f in provider._store.list_facts(limit=100)]
|
||||
|
||||
|
||||
def _user(content, **extra):
|
||||
msg = {"role": "user", "content": content}
|
||||
msg.update(extra)
|
||||
return msg
|
||||
|
||||
|
||||
DECISION_MSG = "we decided to use PostgreSQL for the persistence layer"
|
||||
SUMMARY_MSG = (
|
||||
f"{SUMMARY_PREFIX}\n## Historical Task Snapshot\n"
|
||||
"The project uses a kanban board for all dispatch. "
|
||||
"We agreed to route reviews through the fan-in consumer."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defect 1 — string-boolean gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("off_value", [False, "false", "False", "no", "off", "0", None, ""])
|
||||
def test_auto_extract_off_values_disable_extraction(tmp_path, off_value):
|
||||
provider = _make_provider(tmp_path, auto_extract=off_value)
|
||||
provider.on_session_end([_user(DECISION_MSG)])
|
||||
assert _fact_contents(provider) == []
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defect 2 — compaction summaries harvested as facts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_compaction_summary_not_harvested(tmp_path):
|
||||
"""The exact failure mode from #57682: summary prose matches the decision
|
||||
patterns but must not become a fact."""
|
||||
provider = _make_provider(tmp_path, auto_extract=True)
|
||||
provider.on_session_end([_user(SUMMARY_MSG)])
|
||||
assert _fact_contents(provider) == []
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_metadata_marked_summary_not_harvested(tmp_path):
|
||||
"""In-process summaries carry COMPRESSED_SUMMARY_METADATA_KEY even if a
|
||||
future prefix rewrite changes the content sentinel."""
|
||||
provider = _make_provider(tmp_path, auto_extract=True)
|
||||
marked = _user(DECISION_MSG, **{COMPRESSED_SUMMARY_METADATA_KEY: True})
|
||||
provider.on_session_end([marked])
|
||||
assert _fact_contents(provider) == []
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_merged_into_tail_summary_suffix_not_harvested_prefix_content_ignored(tmp_path):
|
||||
"""Merge-into-tail summaries embed the handoff prefix after the delimiter,
|
||||
not at the start of the message. The wrapped pre-delimiter segment here has
|
||||
no fact-pattern match, so nothing is harvested from either side."""
|
||||
provider = _make_provider(tmp_path, auto_extract=True)
|
||||
merged = _user(
|
||||
f"{_MERGED_PRIOR_CONTEXT_HEADER}\nplease fix the login bug\n"
|
||||
f"{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}"
|
||||
)
|
||||
provider.on_session_end([merged])
|
||||
assert _fact_contents(provider) == []
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
def test_real_user_messages_still_extracted_alongside_summary(tmp_path):
|
||||
"""The guard must skip only the summary, not suppress extraction for the
|
||||
genuine user turns around it."""
|
||||
provider = _make_provider(tmp_path, auto_extract=True)
|
||||
provider.on_session_end(
|
||||
[
|
||||
_user(SUMMARY_MSG),
|
||||
_user("I prefer tabs over spaces for indentation"),
|
||||
]
|
||||
)
|
||||
facts = _fact_contents(provider)
|
||||
assert len(facts) == 1
|
||||
assert "tabs over spaces" in facts[0]
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_compaction_summary_message — public helper contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_helper_detects_prefix_metadata_and_merged_forms():
|
||||
assert is_compaction_summary_message(_user(SUMMARY_MSG))
|
||||
assert is_compaction_summary_message(
|
||||
_user("anything", **{COMPRESSED_SUMMARY_METADATA_KEY: True})
|
||||
)
|
||||
assert is_compaction_summary_message(
|
||||
_user(f"prior tail\n{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}")
|
||||
)
|
||||
assert not is_compaction_summary_message(_user(DECISION_MSG))
|
||||
assert not is_compaction_summary_message(_user(""))
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Tests for FactRetriever FTS5 query sanitization.
|
||||
|
||||
These tests cover the fix where raw natural-language queries passed to
|
||||
FTS5 MATCH were AND-joined by default, dropping recall to zero on any
|
||||
multi-word prose query. The sanitizer drops stopwords and OR-joins the
|
||||
remaining content tokens as phrase literals.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("numpy") # retrieval module imports numpy indirectly
|
||||
|
||||
from plugins.memory.holographic.retrieval import FactRetriever
|
||||
from plugins.memory.holographic.store import MemoryStore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_fts_query — unit tests (no DB required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query,expected_tokens",
|
||||
[
|
||||
# stopwords dropped
|
||||
("what happened with the deployment rollback", {"happened", "deployment", "rollback"}),
|
||||
# single content word passes through
|
||||
("compaction", {"compaction"}),
|
||||
# all stopwords → falls back to raw
|
||||
("the and of", None), # None = sentinel for fallback-to-raw
|
||||
# empty string → empty output
|
||||
("", ""),
|
||||
# FTS5 operator characters stripped
|
||||
("context: length-probe", {"context", "lengthprobe"}),
|
||||
# trailing punctuation stripped by tokenizer
|
||||
("hello, world!", {"hello", "world"}),
|
||||
],
|
||||
)
|
||||
def test_sanitize_fts_query_extracts_content_tokens(query, expected_tokens):
|
||||
result = FactRetriever._sanitize_fts_query(query)
|
||||
|
||||
if expected_tokens == "":
|
||||
assert result == ""
|
||||
return
|
||||
|
||||
if expected_tokens is None:
|
||||
# Pathological case: all stopwords — should fall back to raw query
|
||||
assert result == query
|
||||
return
|
||||
|
||||
# OR-joined phrase literals: `"tok1" OR "tok2" OR ...`
|
||||
# Extract the tokens between quotes, order-independent.
|
||||
import re
|
||||
matches = re.findall(r'"([^"]+)"', result)
|
||||
assert set(matches) == expected_tokens, f"got {result!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration test — actually run _fts_candidates against an in-memory DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def retriever_with_facts(tmp_path):
|
||||
"""MemoryStore seeded with a few facts for retrieval tests."""
|
||||
db_path = tmp_path / "test_facts.db"
|
||||
store = MemoryStore(str(db_path))
|
||||
store.add_fact(
|
||||
content="The Thursday deployment rollback failed because of stale migration state.",
|
||||
category="project",
|
||||
)
|
||||
store.add_fact(
|
||||
content="Compaction settings tuned to 0.85 threshold.",
|
||||
category="tool",
|
||||
)
|
||||
store.add_fact(
|
||||
content="Venice.ai advertises availableContextTokens inside model_spec.",
|
||||
category="tool",
|
||||
)
|
||||
retriever = FactRetriever(store=store)
|
||||
yield retriever
|
||||
store.close()
|
||||
|
||||
|
||||
def test_prefetch_recovers_prose_query(retriever_with_facts):
|
||||
"""A natural-language query should now match the relevant fact.
|
||||
|
||||
Before the sanitizer fix, 'what happened with the deployment rollback'
|
||||
returned zero hits because FTS5 required every token to co-occur.
|
||||
"""
|
||||
results = retriever_with_facts.search(
|
||||
"what happened with the deployment rollback"
|
||||
)
|
||||
assert len(results) >= 1
|
||||
# The top hit should be the deployment rollback fact
|
||||
assert "deployment rollback" in results[0]["content"].lower()
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loop-invariant encode hoists (perf) — search/probe/related must encode
|
||||
# constant vectors ONCE per call, not once per candidate/row.
|
||||
# encode_text/encode_atom are deterministic (SHA-256 counter blocks), so the
|
||||
# hoisted vectors are bit-identical to the per-iteration values they replace.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from plugins.memory.holographic import holographic as hrr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hoisted_retriever(tmp_path):
|
||||
"""30 facts with HRR vectors, default dim (smaller dims trip an
|
||||
inhomogeneous-shape edge in the fact encoder).
|
||||
|
||||
NOTE: a real tmp_path db, NOT ":memory:" — MemoryStore resolves the
|
||||
path and shares one process-wide connection per file, so ":memory:"
|
||||
becomes a literal ./:memory: file that leaks state across runs (and
|
||||
the NULL-vector test below would permanently corrupt it)."""
|
||||
store = MemoryStore(str(tmp_path / "hoist_store.db"))
|
||||
for i in range(30):
|
||||
store.add_fact(
|
||||
content=f"deploy target {i} setting alpha beta gamma option {i % 7}",
|
||||
category="fact" if i % 2 else "preference",
|
||||
tags=f"entity_{i % 5} deploy",
|
||||
)
|
||||
retriever = FactRetriever(store=store)
|
||||
yield retriever
|
||||
store.close()
|
||||
|
||||
|
||||
def _counting_spy(monkeypatch, attr):
|
||||
calls = []
|
||||
real = getattr(hrr, attr)
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
calls.append(args)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(hrr, attr, wrapper)
|
||||
return calls
|
||||
|
||||
|
||||
def test_encode_functions_are_deterministic():
|
||||
"""Soundness premise of the hoists: same input -> identical vector."""
|
||||
import numpy as np
|
||||
|
||||
assert np.array_equal(hrr.encode_text("deploy target", 1024),
|
||||
hrr.encode_text("deploy target", 1024))
|
||||
assert np.array_equal(hrr.encode_atom("__hrr_role_content__", 1024),
|
||||
hrr.encode_atom("__hrr_role_content__", 1024))
|
||||
|
||||
|
||||
def test_search_encodes_query_vector_once(hoisted_retriever, monkeypatch):
|
||||
calls = _counting_spy(monkeypatch, "encode_text")
|
||||
results = hoisted_retriever.search("deploy target setting")
|
||||
assert results # the HRR path actually engaged
|
||||
assert len(calls) == 1, (
|
||||
f"query vector encoded {len(calls)}x in one search() — "
|
||||
"loop-invariant hoist regressed"
|
||||
)
|
||||
|
||||
|
||||
def test_search_results_bit_identical_to_unhoisted(hoisted_retriever):
|
||||
"""Parity: hoisted search() must produce the exact pre-fix results.
|
||||
|
||||
Replicates the pre-fix loop (query vector encoded per candidate) as the
|
||||
reference and compares full scored output for exact equality.
|
||||
"""
|
||||
r = hoisted_retriever
|
||||
query = "deploy target setting"
|
||||
new_results = r.search(query)
|
||||
|
||||
# --- pre-fix reference ---
|
||||
candidates = r._fts_candidates(query, None, 0.3, 10 * 3)
|
||||
query_tokens = r._tokenize(query)
|
||||
scored = []
|
||||
for fact in candidates:
|
||||
content_tokens = r._tokenize(fact["content"])
|
||||
tag_tokens = r._tokenize(fact.get("tags", ""))
|
||||
all_tokens = content_tokens | tag_tokens
|
||||
jaccard = r._jaccard_similarity(query_tokens, all_tokens)
|
||||
fts_score = fact.get("fts_rank", 0.0)
|
||||
if r.hrr_weight > 0 and fact.get("hrr_vector"):
|
||||
fact_vec = hrr.bytes_to_phases(fact["hrr_vector"])
|
||||
query_vec = hrr.encode_text(query, r.hrr_dim) # per-candidate
|
||||
hrr_sim = (hrr.similarity(query_vec, fact_vec) + 1.0) / 2.0
|
||||
else:
|
||||
hrr_sim = 0.5
|
||||
relevance = (r.fts_weight * fts_score
|
||||
+ r.jaccard_weight * jaccard
|
||||
+ r.hrr_weight * hrr_sim)
|
||||
fact["score"] = relevance * fact["trust_score"]
|
||||
scored.append(fact)
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
old_results = scored[:10]
|
||||
for fact in old_results:
|
||||
fact.pop("hrr_vector", None)
|
||||
|
||||
assert new_results == old_results
|
||||
|
||||
|
||||
def test_related_encodes_role_atoms_once(hoisted_retriever, monkeypatch):
|
||||
calls = _counting_spy(monkeypatch, "encode_atom")
|
||||
results = hoisted_retriever.related("entity_1")
|
||||
assert results
|
||||
role_calls = [a for a in calls
|
||||
if a and str(a[0]).startswith("__hrr_role_")]
|
||||
assert len(role_calls) == 2, (
|
||||
f"role atoms encoded {len(role_calls)}x in one related() — "
|
||||
"expected exactly 2 (role_entity + role_content, hoisted)"
|
||||
)
|
||||
|
||||
|
||||
def test_probe_encodes_role_atom_once(hoisted_retriever, monkeypatch):
|
||||
calls = _counting_spy(monkeypatch, "encode_atom")
|
||||
results = hoisted_retriever.probe("entity_1")
|
||||
assert results
|
||||
role_content_calls = [a for a in calls
|
||||
if a and a[0] == "__hrr_role_content__"]
|
||||
assert len(role_content_calls) == 1, (
|
||||
f"role_content atom encoded {len(role_content_calls)}x in one "
|
||||
"probe() — loop-invariant hoist regressed"
|
||||
)
|
||||
|
||||
|
||||
def test_search_without_vectors_never_encodes(hoisted_retriever, monkeypatch):
|
||||
"""Migrated DBs can have FTS candidates with NULL hrr_vector
|
||||
(MemoryStore._init_db adds the column without backfilling existing
|
||||
facts). The lazy hoist must not encode a query vector nothing will
|
||||
use — pre-fix main encoded only beneath fact.get('hrr_vector')."""
|
||||
store = hoisted_retriever.store
|
||||
store._conn.execute("UPDATE facts SET hrr_vector = NULL")
|
||||
store._conn.commit()
|
||||
calls = _counting_spy(monkeypatch, "encode_text")
|
||||
results = hoisted_retriever.search("deploy target setting")
|
||||
assert results # candidates exist; neutral hrr_sim=0.5 path
|
||||
assert calls == [], (
|
||||
f"encode_text called {len(calls)}x with zero vector candidates — "
|
||||
"lazy hoist regressed to eager"
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Regression test for #44037 — holographic provider leaked its SQLite
|
||||
connection to GC on shutdown instead of closing it.
|
||||
|
||||
The corruption-mechanism framing in #44037 (TLS bytes written into the DB via
|
||||
an fd-recycle race) was not reproducible from the code: dropping a sqlite
|
||||
connection flushes valid pages through SQLite's own VFS, never TLS framing, and
|
||||
the provider is at most a *releaser* of DB fds, not the TLS-flushing owner.
|
||||
|
||||
But the underlying resource-hygiene bug is real and is what this test pins:
|
||||
``HolographicMemoryProvider.shutdown()`` must call ``MemoryStore.close()`` so
|
||||
the ``check_same_thread=False`` connection's fd is released deterministically
|
||||
on shutdown, rather than at a non-deterministic GC time on an arbitrary thread.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.holographic import HolographicMemoryProvider
|
||||
|
||||
|
||||
def _make_provider(tmp_path):
|
||||
db_path = str(tmp_path / "memory_store.db")
|
||||
provider = HolographicMemoryProvider(config={"db_path": db_path, "hrr_dim": 64})
|
||||
provider.initialize(session_id="test-session")
|
||||
return provider
|
||||
|
||||
|
||||
def test_shutdown_closes_store_connection(tmp_path):
|
||||
provider = _make_provider(tmp_path)
|
||||
store = provider._store
|
||||
assert store is not None
|
||||
conn = store._conn
|
||||
|
||||
# Connection is live before shutdown.
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
|
||||
provider.shutdown()
|
||||
|
||||
# References are dropped...
|
||||
assert provider._store is None
|
||||
assert provider._retriever is None
|
||||
|
||||
# ...AND the underlying connection was actually closed (not left to GC).
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
|
||||
def test_release_all_under_closes_connections_inside_directory_only(tmp_path):
|
||||
"""Regression test for #88347 — profile delete must break refcounted handles.
|
||||
|
||||
``close()`` alone can never free a database held by a live agent (refs >= 1),
|
||||
and on Windows an open SQLite handle makes rmtree of the profile directory
|
||||
fail with WinError 32. ``release_all_under`` force-closes exactly the shared
|
||||
connections under the doomed directory and leaves everything else alone.
|
||||
"""
|
||||
from plugins.memory.holographic.store import MemoryStore
|
||||
|
||||
profile_dir = tmp_path / "profiles" / "default-2"
|
||||
profile_dir.mkdir(parents=True)
|
||||
inside = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
|
||||
outside = MemoryStore(db_path=tmp_path / "other" / "memory_store.db", hrr_dim=64)
|
||||
inside_conn = inside._conn
|
||||
outside_conn = outside._conn
|
||||
# Both live before the release; the inside one is held "forever" (refs=1,
|
||||
# like a live agent's provider — nobody calls close()).
|
||||
inside_conn.execute("SELECT 1").fetchone()
|
||||
outside_conn.execute("SELECT 1").fetchone()
|
||||
|
||||
try:
|
||||
released = MemoryStore.release_all_under(profile_dir)
|
||||
assert released == 1
|
||||
|
||||
# The doomed connection is really closed despite refs > 0 ...
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
inside_conn.execute("SELECT 1")
|
||||
# ... and the registry entry is gone, so a second release finds
|
||||
# nothing (the CLI-process no-op case).
|
||||
assert str((profile_dir / "memory_store.db").resolve()) not in MemoryStore._shared
|
||||
assert MemoryStore.release_all_under(profile_dir) == 0
|
||||
# A new store on the same path reopens fresh instead of reusing
|
||||
# the dead connection.
|
||||
reopened = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
|
||||
reopened._conn.execute("SELECT 1").fetchone()
|
||||
|
||||
# The sibling outside the directory is untouched.
|
||||
outside_conn.execute("SELECT 1").fetchone()
|
||||
finally:
|
||||
inside.close()
|
||||
reopened.close()
|
||||
outside.close()
|
||||
|
||||
|
||||
|
||||
|
||||
def test_stale_holder_close_does_not_evict_fresh_registry_entry(tmp_path):
|
||||
"""Follow-up to #88347 — a stale holder's late ``close()`` must be inert.
|
||||
|
||||
After ``release_all_under`` force-closes a profile's connection, a store
|
||||
re-created on the same path registers a FRESH shared entry under the same
|
||||
key. If the stale holder (whose entry was force-closed) then calls
|
||||
``close()``, it must not pop the fresh entry out of the registry — that
|
||||
would let a third store open a SECOND connection to the same database and
|
||||
silently reintroduce the multi-writer contention the registry prevents.
|
||||
"""
|
||||
from plugins.memory.holographic.store import MemoryStore
|
||||
|
||||
profile_dir = tmp_path / "profiles" / "default-2"
|
||||
profile_dir.mkdir(parents=True)
|
||||
db_path = profile_dir / "memory_store.db"
|
||||
|
||||
stale = MemoryStore(db_path=db_path, hrr_dim=64)
|
||||
assert MemoryStore.release_all_under(profile_dir) == 1
|
||||
|
||||
fresh = MemoryStore(db_path=db_path, hrr_dim=64)
|
||||
key = fresh._key
|
||||
fresh_entry = MemoryStore._shared[key]
|
||||
|
||||
# The stale holder's late close must leave the fresh entry registered...
|
||||
stale.close()
|
||||
assert MemoryStore._shared.get(key) is fresh_entry
|
||||
# ...and a third store must attach to the SAME shared connection.
|
||||
third = MemoryStore(db_path=db_path, hrr_dim=64)
|
||||
try:
|
||||
assert third._conn is fresh._conn
|
||||
finally:
|
||||
third.close()
|
||||
fresh.close()
|
||||
# Normal last-holder close still evicts its own entry.
|
||||
assert key not in MemoryStore._shared
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for the holographic MemoryStore shared-connection registry.
|
||||
|
||||
MemoryStore instances pointing at the same database file must share one
|
||||
process-wide SQLite connection and one re-entrant lock. Multiple providers
|
||||
coexist in a single process (the main agent plus every delegate_task
|
||||
subagent); when each instance owned a private connection they raced as
|
||||
independent WAL writers and intermittently failed with "database is locked".
|
||||
|
||||
Covers: connection sharing/refcounting, close() semantics, cross-instance
|
||||
visibility, concurrent multi-instance writers, and write-lock release after
|
||||
a failed write.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.holographic.store import MemoryStore
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_shared_registry():
|
||||
"""Each test starts and ends with an empty shared-connection registry."""
|
||||
# Drop any leakage from earlier tests in the same process.
|
||||
for entry in list(MemoryStore._shared.values()):
|
||||
try:
|
||||
entry["conn"].close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
MemoryStore._shared.clear()
|
||||
yield
|
||||
leaked = list(MemoryStore._shared)
|
||||
for entry in list(MemoryStore._shared.values()):
|
||||
try:
|
||||
entry["conn"].close()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
MemoryStore._shared.clear()
|
||||
assert not leaked, f"test leaked shared connections: {leaked}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
return tmp_path / "memory_store.db"
|
||||
|
||||
|
||||
class TestSharedConnection:
|
||||
def test_same_path_shares_one_connection(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
try:
|
||||
assert a._conn is b._conn
|
||||
assert a._lock is b._lock
|
||||
assert len(MemoryStore._shared) == 1
|
||||
assert MemoryStore._shared[str(a.db_path)]["refs"] == 2
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_different_paths_get_distinct_connections(self, tmp_path):
|
||||
a = MemoryStore(tmp_path / "one.db")
|
||||
b = MemoryStore(tmp_path / "two.db")
|
||||
try:
|
||||
assert a._conn is not b._conn
|
||||
assert len(MemoryStore._shared) == 2
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_symlinked_path_shares_connection(self, tmp_path):
|
||||
"""A symlink to the same DB file must hit the same registry entry —
|
||||
otherwise two connections to one file silently reintroduce the
|
||||
multi-writer contention the registry exists to prevent."""
|
||||
real_dir = tmp_path / "real"
|
||||
real_dir.mkdir()
|
||||
link_dir = tmp_path / "link"
|
||||
link_dir.symlink_to(real_dir)
|
||||
|
||||
a = MemoryStore(real_dir / "memory_store.db")
|
||||
b = MemoryStore(link_dir / "memory_store.db")
|
||||
try:
|
||||
assert a._conn is b._conn
|
||||
assert len(MemoryStore._shared) == 1
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_writes_visible_across_instances(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
try:
|
||||
fact_id = a.add_fact("Hermes likes shared connections", category="test")
|
||||
facts = b.list_facts(category="test")
|
||||
assert [f["fact_id"] for f in facts] == [fact_id]
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_schema_initialised_once_per_connection(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path) # must not re-run schema init / WAL probe
|
||||
try:
|
||||
assert MemoryStore._shared[str(a.db_path)]["ready"] is True
|
||||
b.add_fact("schema still works")
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
|
||||
class TestCloseSemantics:
|
||||
def test_closing_one_instance_keeps_sibling_alive(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
a.close()
|
||||
try:
|
||||
# The shared connection must survive the sibling's close().
|
||||
fact_id = b.add_fact("survivor write")
|
||||
assert fact_id > 0
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def test_last_close_releases_connection(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
conn = a._conn
|
||||
a.close()
|
||||
b.close()
|
||||
assert MemoryStore._shared == {}
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1")
|
||||
|
||||
def test_close_is_idempotent(self, db_path):
|
||||
a = MemoryStore(db_path)
|
||||
b = MemoryStore(db_path)
|
||||
a.close()
|
||||
a.close() # double close must not steal b's reference
|
||||
try:
|
||||
b.add_fact("still alive after double close")
|
||||
assert MemoryStore._shared[str(b.db_path)]["refs"] == 1
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
def test_context_manager_releases_reference(self, db_path):
|
||||
with MemoryStore(db_path) as store:
|
||||
store.add_fact("context managed")
|
||||
assert MemoryStore._shared == {}
|
||||
|
||||
def test_reopen_after_full_close(self, db_path):
|
||||
with MemoryStore(db_path) as store:
|
||||
store.add_fact("first lifetime")
|
||||
with MemoryStore(db_path) as store:
|
||||
facts = store.list_facts()
|
||||
assert [f["content"] for f in facts] == ["first lifetime"]
|
||||
|
||||
|
||||
class TestConcurrency:
|
||||
def test_concurrent_multi_instance_writers(self, db_path):
|
||||
"""Many instances writing from many threads must never hit
|
||||
'database is locked' — the failure mode of per-instance connections."""
|
||||
n_threads, n_facts = 8, 15
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def writer(idx: int) -> None:
|
||||
store = MemoryStore(db_path)
|
||||
try:
|
||||
for i in range(n_facts):
|
||||
store.add_fact(f"fact thread={idx} seq={i}", category="load")
|
||||
except BaseException as exc: # noqa: BLE001 - recorded for assert
|
||||
errors.append(exc)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"concurrent writers failed: {errors[:3]}"
|
||||
with MemoryStore(db_path) as store:
|
||||
facts = store.list_facts(category="load", limit=500)
|
||||
assert len(facts) == n_threads * n_facts
|
||||
assert MemoryStore._shared == {}
|
||||
|
||||
def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch):
|
||||
"""A write that raises mid-method must not leave an open transaction
|
||||
holding the SQLite write lock (autocommit isolation_level=None)."""
|
||||
broken = MemoryStore(db_path)
|
||||
sibling = MemoryStore(db_path)
|
||||
try:
|
||||
monkeypatch.setattr(
|
||||
MemoryStore,
|
||||
"_rebuild_bank",
|
||||
lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
broken.add_fact("write that fails after the INSERT")
|
||||
monkeypatch.undo()
|
||||
|
||||
# No dangling transaction: the connection reports autocommit state
|
||||
# and the sibling can write immediately.
|
||||
assert broken._conn.in_transaction is False
|
||||
sibling.add_fact("sibling write right after the failure")
|
||||
finally:
|
||||
broken.close()
|
||||
sibling.close()
|
||||
|
||||
|
||||
class TestProviderShutdown:
|
||||
"""The provider's shutdown() must release its shared connection, not just
|
||||
drop the reference. Leaving finalization to GC keeps the connection (and
|
||||
its write lock) alive on a long-running gateway, which is exactly the
|
||||
"database is locked" contention the shared-connection registry removes."""
|
||||
|
||||
def test_shutdown_releases_shared_connection(self, db_path):
|
||||
from plugins.memory.holographic import HolographicMemoryProvider
|
||||
|
||||
provider = HolographicMemoryProvider(config={"db_path": str(db_path)})
|
||||
provider.initialize("session-shutdown")
|
||||
assert MemoryStore._shared[str(db_path)]["refs"] == 1
|
||||
|
||||
provider.shutdown()
|
||||
|
||||
assert provider._store is None
|
||||
assert MemoryStore._shared == {}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Regression tests for #76414: `hermes honcho peers` showed "(not set)"
|
||||
for every non-default profile.
|
||||
|
||||
_all_profile_host_configs() built the per-profile host key inline as
|
||||
f"{HOST}.{profile}" ("hermes.work") while every other reader/writer —
|
||||
profile_host_key(), resolve_active_host(), honcho status/enable/sync and
|
||||
the runtime plugin — uses the underscore form ("hermes_work"). The lookup
|
||||
always missed, so cmd_peers fell back to "(not set)" and leaked the raw
|
||||
malformed key into the AI-peer column.
|
||||
|
||||
These tests drive the real cmd_peers / _all_profile_host_configs against
|
||||
a real honcho.json (temp HERMES_HOME, no network).
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.memory.honcho.cli as honcho_cli
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def honcho_home(tmp_path, monkeypatch):
|
||||
cfg = {
|
||||
"peerName": "alice",
|
||||
"hosts": {
|
||||
"hermes": {"peerName": "alice", "aiPeer": "hermes"},
|
||||
"hermes_work": {"peerName": "alice", "aiPeer": "hermes"},
|
||||
"hermes_my_profile": {"peerName": "bob", "aiPeer": "hermes"},
|
||||
},
|
||||
}
|
||||
path = tmp_path / "honcho.json"
|
||||
path.write_text(json.dumps(cfg))
|
||||
monkeypatch.setattr(honcho_cli, "_config_path", lambda: path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _peers_output(profiles):
|
||||
buf = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = buf
|
||||
try:
|
||||
honcho_cli.cmd_peers(SimpleNamespace())
|
||||
finally:
|
||||
sys.stdout = old
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestAllProfileHostConfigs:
|
||||
def test_profile_host_keys_match_writer_form(self, honcho_home, monkeypatch):
|
||||
"""The lookup key must be profile_host_key()'s underscore form —
|
||||
the same one honcho sync/enable/status and the runtime write to."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.list_profiles",
|
||||
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
|
||||
)
|
||||
rows = honcho_cli._all_profile_host_configs()
|
||||
by_name = {name: (host, block) for name, host, block in rows}
|
||||
host, block = by_name["work"]
|
||||
assert host == "hermes_work" # not "hermes.work"
|
||||
assert block.get("peerName") == "alice" # the populated block was found
|
||||
|
||||
def test_sanitized_profile_names_resolve(self, honcho_home, monkeypatch):
|
||||
"""Profiles needing sanitization (dots/spaces in the name) also
|
||||
resolve — profile_host_key maps 'my.profile' -> 'hermes_my_profile';
|
||||
the inline dot form never could."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.list_profiles",
|
||||
lambda: [SimpleNamespace(name="default"),
|
||||
SimpleNamespace(name="my.profile")],
|
||||
)
|
||||
rows = honcho_cli._all_profile_host_configs()
|
||||
by_name = {name: block for name, _, block in rows}
|
||||
assert by_name["my.profile"].get("peerName") == "bob"
|
||||
|
||||
def test_legacy_dot_form_host_key_still_readable(self, honcho_home, monkeypatch):
|
||||
"""Back-compat: honcho.json files with LEGACY dot-form host keys
|
||||
("hermes.work") must keep working — the README promises those keys
|
||||
stay readable, and _host_block() exists precisely for that fallback.
|
||||
A bare hosts.get(profile_host_key(...)) would regress them."""
|
||||
path = honcho_home / "honcho.json"
|
||||
cfg = json.loads(path.read_text())
|
||||
del cfg["hosts"]["hermes_work"]
|
||||
cfg["hosts"]["hermes.work"] = {"peerName": "carol", "aiPeer": "hermes"}
|
||||
path.write_text(json.dumps(cfg))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.list_profiles",
|
||||
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
|
||||
)
|
||||
rows = honcho_cli._all_profile_host_configs()
|
||||
by_name = {name: block for name, _, block in rows}
|
||||
assert by_name["work"].get("peerName") == "carol"
|
||||
|
||||
|
||||
class TestCmdPeers:
|
||||
def test_peers_shows_populated_identity_not_host_key_leak(
|
||||
self, honcho_home, monkeypatch):
|
||||
"""Issue #76414's visible symptom: the AI-peer column showed the
|
||||
raw malformed key 'hermes.work' (or '(not set)')."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.list_profiles",
|
||||
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
|
||||
)
|
||||
out = _peers_output(SimpleNamespace())
|
||||
assert "hermes.work" not in out
|
||||
assert "(not set)" not in out
|
||||
# work row shows the populated block's values
|
||||
work_line = [l for l in out.splitlines() if l.strip().startswith("work")][0]
|
||||
assert "alice" in work_line and "hermes" in work_line
|
||||
|
||||
def test_peers_falls_back_cleanly_when_block_missing(
|
||||
self, honcho_home, monkeypatch):
|
||||
"""A profile with no host block still falls back to the top-level
|
||||
peerName and the (well-formed) host key — not a crash or a leak."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.list_profiles",
|
||||
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="new")],
|
||||
)
|
||||
out = _peers_output(SimpleNamespace())
|
||||
assert "hermes.new" not in out # well-formed key, no dot-form leak
|
||||
new_line = [l for l in out.splitlines() if l.strip().startswith("new")][0]
|
||||
assert "alice" in new_line # top-level peerName fallback
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for Honcho's declared config surface."""
|
||||
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_BOOL,
|
||||
KIND_JSON,
|
||||
KIND_NUMBER,
|
||||
KIND_SECRET,
|
||||
KIND_SELECT,
|
||||
STORAGE_HONCHO_HOST_BLOCK,
|
||||
get_provider_config_schema,
|
||||
)
|
||||
|
||||
# The curated set shown in the compact panel; everything else lives in the modal.
|
||||
INLINE_KEYS = {
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
"environment",
|
||||
"workspace",
|
||||
"peerName",
|
||||
"aiPeer",
|
||||
"sessionStrategy",
|
||||
}
|
||||
|
||||
|
||||
def test_honcho_is_declared():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
|
||||
assert provider is not None
|
||||
assert provider.label == "Honcho"
|
||||
assert provider.storage == STORAGE_HONCHO_HOST_BLOCK
|
||||
# Field keys are unique, and the curated inline set is present.
|
||||
keys = [field.key for field in provider.fields]
|
||||
assert len(keys) == len(set(keys))
|
||||
assert INLINE_KEYS <= set(keys)
|
||||
|
||||
|
||||
def test_inline_fields_are_the_curated_subset():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
assert {field.key for field in provider.inline_fields()} == INLINE_KEYS
|
||||
# The modal-only fields are a non-empty remainder.
|
||||
non_inline = {f.key for f in provider.fields} - INLINE_KEYS
|
||||
assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline
|
||||
|
||||
|
||||
def test_declares_the_new_field_kinds():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
by_key = {f.key: f for f in provider.fields}
|
||||
assert by_key["saveMessages"].kind == KIND_BOOL
|
||||
assert by_key["dialecticMaxChars"].kind == KIND_NUMBER
|
||||
assert by_key["userPeerAliases"].kind == KIND_JSON
|
||||
assert by_key["recallMode"].allowed_values() == {"hybrid", "context", "tools"}
|
||||
assert by_key["observationMode"].allowed_values() == {"directional", "unified"}
|
||||
|
||||
|
||||
def test_selects_constrain_their_values():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
environment = next(f for f in provider.fields if f.key == "environment")
|
||||
assert environment.kind == KIND_SELECT
|
||||
# Honcho SDK only accepts local/production; "demo" is not a valid environment.
|
||||
assert environment.allowed_values() == {"production", "local"}
|
||||
|
||||
strategy = next(f for f in provider.fields if f.key == "sessionStrategy")
|
||||
assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"}
|
||||
|
||||
|
||||
def test_api_key_is_a_secret_bound_to_env():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
api_key = next(f for f in provider.fields if f.key == "apiKey")
|
||||
assert api_key.kind == KIND_SECRET
|
||||
assert api_key.is_secret is True
|
||||
assert api_key.env_key == "HONCHO_API_KEY"
|
||||
|
||||
|
||||
def test_root_scoped_fields_are_exactly_the_global_ones():
|
||||
provider = get_provider_config_schema("honcho")
|
||||
assert provider is not None
|
||||
|
||||
scopes = {f.key: f.scope for f in provider.fields}
|
||||
root_keys = {k for k, scope in scopes.items() if scope == "root"}
|
||||
# baseUrl, timeout and sessions live at the config root in Honcho's schema;
|
||||
# everything else is per-profile host-scoped.
|
||||
assert root_keys == {"baseUrl", "timeout", "sessions"}
|
||||
@@ -0,0 +1,680 @@
|
||||
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
|
||||
|
||||
import copy
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.mem0._backend import (
|
||||
Mem0Backend,
|
||||
PlatformBackend,
|
||||
OSSBackend,
|
||||
SelfHostedBackend,
|
||||
)
|
||||
|
||||
|
||||
class FakePlatformClient:
|
||||
"""Fake MemoryClient for PlatformBackend tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def search(self, query, **kwargs):
|
||||
self.calls.append(("search", query, kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.9}]}
|
||||
|
||||
def get_all(self, **kwargs):
|
||||
self.calls.append(("get_all", kwargs))
|
||||
return {"count": 1, "next": None, "results": [{"id": "m1", "memory": "fact1"}]}
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
self.calls.append(("add", messages, kwargs))
|
||||
return {"status": "PENDING", "event_id": "evt-1"}
|
||||
|
||||
def update(self, **kwargs):
|
||||
self.calls.append(("update", kwargs))
|
||||
return {"id": kwargs["memory_id"], "text": kwargs["text"]}
|
||||
|
||||
def delete(self, **kwargs):
|
||||
self.calls.append(("delete", kwargs))
|
||||
|
||||
|
||||
class TestPlatformBackend:
|
||||
|
||||
def _make(self):
|
||||
client = FakePlatformClient()
|
||||
backend = PlatformBackend.__new__(PlatformBackend)
|
||||
backend._client = client
|
||||
return backend, client
|
||||
|
||||
def test_search_forwards_params(self):
|
||||
backend, client = self._make()
|
||||
result = backend.search("test query", filters={"user_id": "u1"}, top_k=5)
|
||||
assert client.calls[0][0] == "search"
|
||||
assert client.calls[0][1] == "test query"
|
||||
assert client.calls[0][2]["filters"] == {"user_id": "u1"}
|
||||
assert client.calls[0][2]["top_k"] == 5
|
||||
|
||||
|
||||
def test_add_forwards_kwargs(self):
|
||||
backend, client = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
result = backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
|
||||
call = client.calls[0]
|
||||
assert call[2]["user_id"] == "u1"
|
||||
assert call[2]["infer"] is False
|
||||
# metadata kwarg should be omitted entirely when not provided so we
|
||||
# don't surprise older mem0 client versions with an unknown kwarg.
|
||||
assert "metadata" not in call[2]
|
||||
|
||||
|
||||
def test_update_forwards(self):
|
||||
backend, client = self._make()
|
||||
backend.update("m1", "new text")
|
||||
assert client.calls[0][1] == {"memory_id": "m1", "text": "new text"}
|
||||
|
||||
def test_delete_forwards(self):
|
||||
backend, client = self._make()
|
||||
backend.delete("m1")
|
||||
assert client.calls[0][1] == {"memory_id": "m1"}
|
||||
|
||||
|
||||
class FakeOSSMemory:
|
||||
"""Fake mem0.Memory for OSSBackend tests."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def search(self, query, **kwargs):
|
||||
self.calls.append(("search", query, kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.8}]}
|
||||
|
||||
def get_all(self, **kwargs):
|
||||
self.calls.append(("get_all", kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1"}]}
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
self.calls.append(("add", messages, kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "fact1", "event": "ADD"}]}
|
||||
|
||||
def update(self, memory_id, **kwargs):
|
||||
self.calls.append(("update", memory_id, kwargs))
|
||||
return {"message": "Memory updated successfully!"}
|
||||
|
||||
def delete(self, memory_id):
|
||||
self.calls.append(("delete", memory_id))
|
||||
return {"message": "Memory deleted successfully!"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeMem0State:
|
||||
factory_registrations: list = field(default_factory=list)
|
||||
from_config_calls: int = 0
|
||||
clients: list = field(default_factory=list)
|
||||
requests: list = field(default_factory=list)
|
||||
|
||||
|
||||
def _install_fake_mem0(monkeypatch):
|
||||
"""Install a small mem0 2.0.10-shaped surface for OSS backend tests."""
|
||||
|
||||
state = _FakeMem0State()
|
||||
|
||||
class BaseLlmConfig:
|
||||
def __init__(
|
||||
self,
|
||||
model=None,
|
||||
temperature=0.1,
|
||||
api_key=None,
|
||||
max_tokens=2000,
|
||||
top_p=0.1,
|
||||
top_k=1,
|
||||
enable_vision=False,
|
||||
vision_details="auto",
|
||||
reasoning_effort=None,
|
||||
http_client_proxies=None,
|
||||
is_reasoning_model=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.model = model
|
||||
self.temperature = temperature
|
||||
self.api_key = api_key
|
||||
self.max_tokens = max_tokens
|
||||
self.top_p = top_p
|
||||
self.top_k = top_k
|
||||
self.enable_vision = enable_vision
|
||||
self.vision_details = vision_details
|
||||
self.reasoning_effort = reasoning_effort
|
||||
self.http_client_proxies = http_client_proxies
|
||||
self.is_reasoning_model = is_reasoning_model
|
||||
for name, value in kwargs.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
class OpenAIConfig(BaseLlmConfig):
|
||||
def __init__(
|
||||
self,
|
||||
model=None,
|
||||
temperature=0.1,
|
||||
api_key=None,
|
||||
max_tokens=2000,
|
||||
top_p=0.1,
|
||||
top_k=1,
|
||||
enable_vision=False,
|
||||
vision_details="auto",
|
||||
reasoning_effort=None,
|
||||
http_client_proxies=None,
|
||||
is_reasoning_model=None,
|
||||
openai_base_url=None,
|
||||
models=None,
|
||||
route="fallback",
|
||||
openrouter_base_url=None,
|
||||
site_url=None,
|
||||
app_name=None,
|
||||
store=None,
|
||||
response_callback=None,
|
||||
):
|
||||
super().__init__(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
api_key=api_key,
|
||||
max_tokens=max_tokens,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
enable_vision=enable_vision,
|
||||
vision_details=vision_details,
|
||||
reasoning_effort=reasoning_effort,
|
||||
http_client_proxies=http_client_proxies,
|
||||
is_reasoning_model=is_reasoning_model,
|
||||
)
|
||||
self.openai_base_url = openai_base_url
|
||||
self.models = models
|
||||
self.route = route
|
||||
self.openrouter_base_url = openrouter_base_url
|
||||
self.site_url = site_url
|
||||
self.app_name = app_name
|
||||
self.store = store
|
||||
self.response_callback = response_callback
|
||||
|
||||
class LLMBase:
|
||||
def __init__(self, config=None):
|
||||
self.config = config or BaseLlmConfig()
|
||||
if not hasattr(self.config, "model"):
|
||||
raise ValueError("Configuration must have a 'model' attribute")
|
||||
|
||||
def _get_supported_params(self, **kwargs):
|
||||
if self.config.is_reasoning_model:
|
||||
return {
|
||||
name: kwargs[name]
|
||||
for name in ("messages", "response_format", "tools", "tool_choice")
|
||||
if name in kwargs
|
||||
}
|
||||
params = {
|
||||
"temperature": self.config.temperature,
|
||||
"top_p": self.config.top_p,
|
||||
"max_tokens": self.config.max_tokens,
|
||||
}
|
||||
params.update(kwargs)
|
||||
return params
|
||||
|
||||
class OpenAILLM(LLMBase):
|
||||
@staticmethod
|
||||
def _parse_response(response, tools):
|
||||
if not tools:
|
||||
return response.choices[0].message.content
|
||||
parsed = {
|
||||
"content": response.choices[0].message.content,
|
||||
"tool_calls": [],
|
||||
}
|
||||
for tool_call in response.choices[0].message.tool_calls or []:
|
||||
parsed["tool_calls"].append(
|
||||
{
|
||||
"name": tool_call.function.name,
|
||||
"arguments": json.loads(tool_call.function.arguments),
|
||||
}
|
||||
)
|
||||
return parsed
|
||||
|
||||
class Factory:
|
||||
provider_to_class = {
|
||||
"openai": ("mem0.llms.openai.OpenAILLM", OpenAIConfig),
|
||||
"ollama": ("mem0.llms.openai.OpenAILLM", BaseLlmConfig),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def register_provider(cls, name, class_path, config_class=None):
|
||||
cls.provider_to_class[name] = (
|
||||
class_path,
|
||||
config_class or BaseLlmConfig,
|
||||
)
|
||||
state.factory_registrations.append((name, class_path, config_class))
|
||||
|
||||
@classmethod
|
||||
def create(cls, provider_name, config=None, **kwargs):
|
||||
class_path, config_class = cls.provider_to_class[provider_name]
|
||||
if config is None:
|
||||
config = config_class(**kwargs)
|
||||
elif isinstance(config, dict):
|
||||
config = config_class(**config)
|
||||
module_name, class_name = class_path.rsplit(".", 1)
|
||||
llm_class = getattr(importlib.import_module(module_name), class_name)
|
||||
return llm_class(config)
|
||||
|
||||
class MemoryConfig:
|
||||
def __init__(self, **config):
|
||||
llm = config["llm"]
|
||||
if llm["provider"] not in {"openai", "ollama"}:
|
||||
raise ValueError(
|
||||
f"Unsupported LLM provider: {llm['provider']}"
|
||||
)
|
||||
self.llm = SimpleNamespace(
|
||||
provider=llm["provider"],
|
||||
config=copy.deepcopy(llm.get("config", {})),
|
||||
)
|
||||
embedder = config["embedder"]
|
||||
self.embedder = SimpleNamespace(
|
||||
provider=embedder["provider"],
|
||||
config=copy.deepcopy(embedder.get("config", {})),
|
||||
)
|
||||
vector_store = config["vector_store"]
|
||||
self.vector_store = SimpleNamespace(
|
||||
provider=vector_store["provider"],
|
||||
config=copy.deepcopy(vector_store.get("config", {})),
|
||||
)
|
||||
self.version = config.get("version", "v1.1")
|
||||
|
||||
class Memory:
|
||||
instances = []
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.llm = Factory.create(config.llm.provider, config.llm.config)
|
||||
self.embedding_model = SimpleNamespace(
|
||||
provider=config.embedder.provider,
|
||||
config=config.embedder.config,
|
||||
)
|
||||
self.vector_store = SimpleNamespace(
|
||||
provider=config.vector_store.provider,
|
||||
config=config.vector_store.config,
|
||||
)
|
||||
type(self).instances.append(self)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config):
|
||||
# This mirrors mem0 2.0.10: validation rejects the private provider
|
||||
# before the factory gets a chance to resolve its registration.
|
||||
state.from_config_calls += 1
|
||||
return cls(MemoryConfig(**config))
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, *, api_key, base_url):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
state.clients.append(self)
|
||||
self.chat = SimpleNamespace(
|
||||
completions=SimpleNamespace(create=self._create)
|
||||
)
|
||||
|
||||
def _create(self, **params):
|
||||
state.requests.append(params)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
content="direct answer",
|
||||
tool_calls=[
|
||||
SimpleNamespace(
|
||||
function=SimpleNamespace(
|
||||
name="remember",
|
||||
arguments='{"fact": "tea"}',
|
||||
)
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
package_names = {
|
||||
"mem0": types.ModuleType("mem0"),
|
||||
"mem0.configs": types.ModuleType("mem0.configs"),
|
||||
"mem0.configs.llms": types.ModuleType("mem0.configs.llms"),
|
||||
"mem0.llms": types.ModuleType("mem0.llms"),
|
||||
"mem0.utils": types.ModuleType("mem0.utils"),
|
||||
"mem0.configs.base": types.ModuleType("mem0.configs.base"),
|
||||
"mem0.configs.llms.base": types.ModuleType("mem0.configs.llms.base"),
|
||||
"mem0.configs.llms.openai": types.ModuleType("mem0.configs.llms.openai"),
|
||||
"mem0.llms.base": types.ModuleType("mem0.llms.base"),
|
||||
"mem0.llms.openai": types.ModuleType("mem0.llms.openai"),
|
||||
"mem0.utils.factory": types.ModuleType("mem0.utils.factory"),
|
||||
"openai": types.ModuleType("openai"),
|
||||
}
|
||||
setattr(package_names["mem0"], "Memory", Memory)
|
||||
setattr(package_names["mem0.configs.base"], "MemoryConfig", MemoryConfig)
|
||||
setattr(package_names["mem0.configs.llms.base"], "BaseLlmConfig", BaseLlmConfig)
|
||||
setattr(package_names["mem0.configs.llms.openai"], "OpenAIConfig", OpenAIConfig)
|
||||
setattr(package_names["mem0.llms.base"], "LLMBase", LLMBase)
|
||||
setattr(package_names["mem0.llms.openai"], "OpenAILLM", OpenAILLM)
|
||||
setattr(package_names["mem0.utils.factory"], "LlmFactory", Factory)
|
||||
setattr(package_names["openai"], "OpenAI", FakeOpenAI)
|
||||
for name, module in package_names.items():
|
||||
if name in {"mem0", "mem0.configs", "mem0.configs.llms", "mem0.llms", "mem0.utils"}:
|
||||
module.__path__ = []
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
|
||||
# The class-path registration imports this module after the fake mem0
|
||||
# surface is installed, so it binds to the test doubles above.
|
||||
monkeypatch.delitem(
|
||||
sys.modules, "plugins.memory.mem0._openai_llm", raising=False
|
||||
)
|
||||
return state, Memory, Factory
|
||||
|
||||
|
||||
class TestOSSBackend:
|
||||
|
||||
def _make(self):
|
||||
memory = FakeOSSMemory()
|
||||
backend = OSSBackend.__new__(OSSBackend)
|
||||
backend._memory = memory
|
||||
return backend, memory
|
||||
|
||||
|
||||
def test_legacy_api_base_aliases_are_normalized_before_mem0_init(self, monkeypatch):
|
||||
state, Memory, factory = _install_fake_mem0(monkeypatch)
|
||||
raw = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "openai-sentinel",
|
||||
"api_base": "https://llm.example/v1",
|
||||
},
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "ollama",
|
||||
"config": {"model": "nomic-embed-text", "api_base": "http://ollama:11434"},
|
||||
},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
before = copy.deepcopy(raw)
|
||||
environment = dict(os.environ)
|
||||
|
||||
OSSBackend(raw)
|
||||
|
||||
assert len(Memory.instances) == 1
|
||||
captured = Memory.instances[0].config
|
||||
assert captured.llm.provider == "hermes_openai"
|
||||
assert captured.llm.config["openai_base_url"] == "https://llm.example/v1"
|
||||
assert captured.embedder.provider == "ollama"
|
||||
assert captured.embedder.config["ollama_base_url"] == "http://ollama:11434"
|
||||
assert "api_base" not in captured.llm.config
|
||||
assert "api_base" not in captured.embedder.config
|
||||
assert factory.provider_to_class["hermes_openai"][1].__name__ == "OpenAIConfig"
|
||||
assert len(state.factory_registrations) == 1
|
||||
assert state.from_config_calls == 0
|
||||
assert raw == before
|
||||
assert dict(os.environ) == environment
|
||||
|
||||
def test_direct_openai_uses_openai_credentials_and_request_shape(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
callback_calls = []
|
||||
config = factory.provider_to_class["openai"][1](
|
||||
model="gpt-5-mini",
|
||||
api_key="configured-openai-sentinel",
|
||||
openai_base_url="https://openai.example/v1",
|
||||
models=["router-model"],
|
||||
route="lowest-latency",
|
||||
site_url="https://hermes.example",
|
||||
app_name="Hermes",
|
||||
store=True,
|
||||
response_callback=lambda *args: callback_calls.append(args),
|
||||
)
|
||||
adapter = module.DirectOpenAILLM(config)
|
||||
assert adapter.config.is_reasoning_model is True
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "remember", "parameters": {}},
|
||||
}
|
||||
]
|
||||
|
||||
result = adapter.generate_response(
|
||||
[{"role": "user", "content": "remember tea"}],
|
||||
response_format={"type": "json_object"},
|
||||
tools=tools,
|
||||
tool_choice="required",
|
||||
)
|
||||
|
||||
assert len(state.clients) == 1
|
||||
client = state.clients[0]
|
||||
assert client.api_key == "configured-openai-sentinel"
|
||||
assert client.base_url == "https://openai.example/v1"
|
||||
request = state.requests[0]
|
||||
assert request["model"] == "gpt-5-mini"
|
||||
assert request["tools"] == tools
|
||||
assert request["tool_choice"] == "required"
|
||||
assert request["response_format"] == {"type": "json_object"}
|
||||
assert request["store"] is True
|
||||
assert "models" not in request
|
||||
assert "route" not in request
|
||||
assert "extra_headers" not in request
|
||||
assert "temperature" not in request
|
||||
assert "top_p" not in request
|
||||
assert "max_tokens" not in request
|
||||
assert result == {
|
||||
"content": "direct answer",
|
||||
"tool_calls": [{"name": "remember", "arguments": {"fact": "tea"}}],
|
||||
}
|
||||
assert len(callback_calls) == 1
|
||||
assert callback_calls[0][0] is adapter
|
||||
assert callback_calls[0][2] == request
|
||||
|
||||
def test_direct_openai_preserves_explicit_non_reasoning_override(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
config = factory.provider_to_class["openai"][1](
|
||||
model="gpt-5-mini",
|
||||
api_key="configured-openai-sentinel",
|
||||
is_reasoning_model=False,
|
||||
)
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
adapter = module.DirectOpenAILLM(config)
|
||||
adapter.generate_response([{"role": "user", "content": "remember tea"}])
|
||||
|
||||
assert adapter.config.is_reasoning_model is False
|
||||
request = state.requests[0]
|
||||
assert request["temperature"] == 0.1
|
||||
assert request["top_p"] == 0.1
|
||||
assert request["max_tokens"] == 2000
|
||||
|
||||
def test_direct_openai_defaults_missing_model_to_reasoning_safe_mini(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
|
||||
_install_fake_mem0(monkeypatch)
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
adapter = module.DirectOpenAILLM()
|
||||
|
||||
assert adapter.config.model == "gpt-5-mini"
|
||||
assert adapter.config.is_reasoning_model is True
|
||||
|
||||
def test_direct_openai_uses_openai_environment_when_config_omits_values(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "https://env-openai.example/v1")
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
config = factory.provider_to_class["openai"][1](model="gpt-5-mini")
|
||||
adapter = module.DirectOpenAILLM(config)
|
||||
|
||||
assert len(state.clients) == 1
|
||||
assert state.clients[0].api_key == "env-openai-sentinel"
|
||||
assert state.clients[0].base_url == "https://env-openai.example/v1"
|
||||
|
||||
def test_missing_openai_key_fails_before_client_and_hides_router_secret(self, monkeypatch):
|
||||
state, _, factory = _install_fake_mem0(monkeypatch)
|
||||
router_secret = "router-secret-sentinel"
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", router_secret)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
module = importlib.import_module("plugins.memory.mem0._openai_llm")
|
||||
config = factory.provider_to_class["openai"][1](
|
||||
model="gpt-5-mini",
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
module.DirectOpenAILLM(config)
|
||||
|
||||
assert "OpenAI API key" in str(exc_info.value)
|
||||
assert router_secret not in str(exc_info.value)
|
||||
assert state.clients == []
|
||||
assert state.requests == []
|
||||
|
||||
def test_registration_is_idempotent_and_clients_keep_instance_config(self, monkeypatch):
|
||||
state, Memory, factory = _install_fake_mem0(monkeypatch)
|
||||
first = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "first-openai-sentinel",
|
||||
"openai_base_url": "https://first.example/v1",
|
||||
},
|
||||
},
|
||||
"embedder": {"provider": "ollama", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
second = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "second-openai-sentinel",
|
||||
"openai_base_url": "https://second.example/v1",
|
||||
},
|
||||
},
|
||||
"embedder": {"provider": "ollama", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
first_before = copy.deepcopy(first)
|
||||
second_before = copy.deepcopy(second)
|
||||
|
||||
OSSBackend(first)
|
||||
OSSBackend(second)
|
||||
|
||||
assert len(state.factory_registrations) == 1
|
||||
assert factory.provider_to_class["hermes_openai"][0].endswith(
|
||||
"_openai_llm.DirectOpenAILLM"
|
||||
)
|
||||
assert [
|
||||
(client.api_key, client.base_url) for client in state.clients
|
||||
] == [
|
||||
("first-openai-sentinel", "https://first.example/v1"),
|
||||
("second-openai-sentinel", "https://second.example/v1"),
|
||||
]
|
||||
assert len(Memory.instances) == 2
|
||||
assert state.from_config_calls == 0
|
||||
assert first == first_before
|
||||
assert second == second_before
|
||||
|
||||
def test_ollama_bypasses_direct_openai_adapter(self, monkeypatch):
|
||||
state, Memory, factory = _install_fake_mem0(monkeypatch)
|
||||
raw = {
|
||||
"llm": {
|
||||
"provider": "ollama",
|
||||
"config": {
|
||||
"model": "llama3.1:8b",
|
||||
"api_base": "http://ollama:11434",
|
||||
},
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "ollama",
|
||||
"config": {
|
||||
"model": "nomic-embed-text",
|
||||
"api_base": "http://ollama:11434",
|
||||
},
|
||||
},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
before = copy.deepcopy(raw)
|
||||
|
||||
OSSBackend(raw)
|
||||
|
||||
assert len(Memory.instances) == 1
|
||||
assert state.from_config_calls == 1
|
||||
assert Memory.instances[0].config.llm.provider == "ollama"
|
||||
assert Memory.instances[0].config.embedder.provider == "ollama"
|
||||
assert "hermes_openai" not in factory.provider_to_class
|
||||
assert state.clients == []
|
||||
assert raw == before
|
||||
|
||||
|
||||
httpx = pytest.importorskip("httpx")
|
||||
|
||||
|
||||
class _StubServer:
|
||||
"""Records requests and serves the real self-hosted server's response shapes."""
|
||||
|
||||
def __init__(self, rows=10):
|
||||
self.requests = []
|
||||
self._rows = [{"id": f"m{i}", "memory": f"f{i}"} for i in range(rows)]
|
||||
|
||||
def handler(self, request):
|
||||
self.requests.append(request)
|
||||
path, method = request.url.path, request.method
|
||||
if path == "/search" and method == "POST":
|
||||
return httpx.Response(200, json={"results": [{"id": "m1", "memory": "tea", "score": 0.9}]})
|
||||
if path == "/memories" and method == "GET":
|
||||
top_k = int(request.url.params.get("top_k", len(self._rows)))
|
||||
return httpx.Response(200, json={"results": self._rows[:top_k]})
|
||||
if path == "/memories" and method == "POST":
|
||||
return httpx.Response(200, json={"results": [{"id": "new", "memory": "stored", "event": "ADD"}]})
|
||||
if path.startswith("/memories/") and method in ("PUT", "DELETE"):
|
||||
if path.endswith("/missing"): # server 404s unknown ids
|
||||
return httpx.Response(404, json={"detail": "Memory not found"})
|
||||
verb = "updated" if method == "PUT" else "Memory deleted successfully"
|
||||
return httpx.Response(200, json={"message": verb})
|
||||
return httpx.Response(404, json={"detail": "not found"})
|
||||
|
||||
|
||||
def _backend(server, api_key="adminkey", host="http://sh:8888"):
|
||||
"""Build a SelfHostedBackend routed through the stub transport.
|
||||
|
||||
Uses the real __init__ (via the injectable ``transport`` kwarg) so the
|
||||
constructor's header/base_url setup is exercised by every test here.
|
||||
"""
|
||||
return SelfHostedBackend(
|
||||
api_key, host, transport=httpx.MockTransport(server.handler)
|
||||
)
|
||||
|
||||
|
||||
class TestSelfHostedBackend:
|
||||
# --- constructor / auth setup (the crux of the bug) -------------------
|
||||
|
||||
def test_init_uses_x_api_key_not_token_auth(self):
|
||||
b = SelfHostedBackend("adminkey", "http://sh:8888")
|
||||
assert b._client.headers["x-api-key"] == "adminkey"
|
||||
assert "authorization" not in b._client.headers # NOT the cloud 'Token' scheme
|
||||
|
||||
|
||||
# --- search ----------------------------------------------------------
|
||||
|
||||
|
||||
# --- add / update / delete ------------------------------------------
|
||||
|
||||
|
||||
# --- error propagation (feeds the plugin's circuit breaker) ----------
|
||||
|
||||
def test_http_error_raises(self):
|
||||
s = _StubServer()
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
_backend(s).delete("missing") # 404 -> raise_for_status; 'not found' won't trip breaker
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Integration coverage for Hermes' pinned Mem0 OSS boundary."""
|
||||
|
||||
import copy
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
pytest.importorskip("mem0", reason="requires the existing mem0 extra")
|
||||
|
||||
|
||||
def test_openai_backend_uses_real_mem0_config_and_factory(monkeypatch, tmp_path):
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
monkeypatch.setenv("MEM0_DIR", str(mem0_dir))
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
|
||||
|
||||
import openai
|
||||
from mem0.memory import main as memory_main
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
from plugins.memory.mem0._backend import OSSBackend
|
||||
from plugins.memory.mem0._openai_llm import DirectOpenAILLM
|
||||
|
||||
clients = []
|
||||
requests = []
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, *, api_key, base_url):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.chat = SimpleNamespace(
|
||||
completions=SimpleNamespace(create=self._create)
|
||||
)
|
||||
clients.append(self)
|
||||
|
||||
@staticmethod
|
||||
def _create(**params):
|
||||
requests.append(params)
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(
|
||||
content="direct answer",
|
||||
tool_calls=None,
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
class DummyVectorStore:
|
||||
pass
|
||||
|
||||
class DummyDB:
|
||||
def __init__(self, _path):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(
|
||||
LlmFactory,
|
||||
"provider_to_class",
|
||||
dict(LlmFactory.provider_to_class),
|
||||
)
|
||||
monkeypatch.setattr(openai, "OpenAI", FakeOpenAI)
|
||||
monkeypatch.setattr(
|
||||
memory_main.EmbedderFactory,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
memory_main.VectorStoreFactory,
|
||||
"create",
|
||||
lambda *_args, **_kwargs: DummyVectorStore(),
|
||||
)
|
||||
monkeypatch.setattr(memory_main, "SQLiteManager", DummyDB)
|
||||
monkeypatch.setattr(memory_main, "MEM0_TELEMETRY", False)
|
||||
monkeypatch.setattr(memory_main, "capture_event", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
OSSBackend,
|
||||
"_recreate_collection_if_dims_changed",
|
||||
staticmethod(lambda *_args, **_kwargs: None),
|
||||
)
|
||||
|
||||
config = {
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"config": {
|
||||
"model": "gpt-5-mini",
|
||||
"api_key": "configured-openai-sentinel",
|
||||
"openai_base_url": "https://openai.example/v1",
|
||||
"models": ["router-model"],
|
||||
"route": "lowest-latency",
|
||||
},
|
||||
},
|
||||
"embedder": {
|
||||
"provider": "ollama",
|
||||
"config": {
|
||||
"model": "nomic-embed-text",
|
||||
"ollama_base_url": "http://ollama.example:11434",
|
||||
"embedding_dims": 768,
|
||||
},
|
||||
},
|
||||
"vector_store": {
|
||||
"provider": "qdrant",
|
||||
"config": {
|
||||
"collection_name": "mem0",
|
||||
"path": str(tmp_path / "qdrant"),
|
||||
},
|
||||
},
|
||||
}
|
||||
original_config = copy.deepcopy(config)
|
||||
environment = dict(os.environ)
|
||||
|
||||
backend = OSSBackend(config)
|
||||
result = backend._memory.llm.generate_response(
|
||||
[{"role": "user", "content": "remember tea"}]
|
||||
)
|
||||
|
||||
assert isinstance(backend._memory.llm, DirectOpenAILLM)
|
||||
assert len(clients) == 1
|
||||
assert clients[0].api_key == "configured-openai-sentinel"
|
||||
assert clients[0].base_url == "https://openai.example/v1"
|
||||
assert requests == [
|
||||
{
|
||||
"model": "gpt-5-mini",
|
||||
"messages": [{"role": "user", "content": "remember tea"}],
|
||||
}
|
||||
]
|
||||
assert result == "direct answer"
|
||||
assert config == original_config
|
||||
assert dict(os.environ) == environment
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for OSS provider definitions and validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.mem0._oss_providers import (
|
||||
LLM_PROVIDERS,
|
||||
EMBEDDER_PROVIDERS,
|
||||
VECTOR_PROVIDERS,
|
||||
KNOWN_DIMS,
|
||||
validate_oss_config,
|
||||
)
|
||||
|
||||
|
||||
class TestProviderDefinitions:
|
||||
|
||||
def test_llm_providers_have_required_keys(self):
|
||||
for pid, p in LLM_PROVIDERS.items():
|
||||
assert "label" in p
|
||||
assert "needs_key" in p
|
||||
assert "default_model" in p
|
||||
|
||||
def test_embedder_providers_have_required_keys(self):
|
||||
for pid, p in EMBEDDER_PROVIDERS.items():
|
||||
assert "label" in p
|
||||
assert "needs_key" in p
|
||||
assert "default_model" in p
|
||||
assert "dims" in p
|
||||
|
||||
|
||||
def test_vector_providers_have_required_keys(self):
|
||||
for pid, p in VECTOR_PROVIDERS.items():
|
||||
assert "label" in p
|
||||
assert "default_config" in p
|
||||
|
||||
|
||||
def test_known_dims_covers_defaults(self):
|
||||
for pid, p in EMBEDDER_PROVIDERS.items():
|
||||
assert p["default_model"] in KNOWN_DIMS
|
||||
|
||||
|
||||
class TestValidation:
|
||||
|
||||
def test_valid_openai_config(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}},
|
||||
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
|
||||
"vector_store": {"provider": "qdrant", "config": {"path": "/tmp/test"}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert errors == []
|
||||
|
||||
def test_unknown_llm_provider(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "gemini", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("llm" in e.lower() for e in errors)
|
||||
|
||||
|
||||
def test_missing_llm_section(self):
|
||||
cfg = {
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("llm" in e.lower() for e in errors)
|
||||
|
||||
def test_pgvector_needs_user(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "pgvector", "config": {"host": "localhost"}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("user" in e.lower() for e in errors)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for Mem0 setup wizard — flag parsing, config building, validation."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from plugins.memory.mem0._setup import (
|
||||
parse_flags,
|
||||
build_oss_config,
|
||||
_write_env,
|
||||
_prompt_api_key,
|
||||
post_setup,
|
||||
_check_qdrant_path,
|
||||
_check_ollama,
|
||||
_check_pgvector,
|
||||
)
|
||||
|
||||
|
||||
def _inject_fake_hermes_cli(monkeypatch):
|
||||
"""Inject fake hermes_cli modules so yaml/curses aren't required."""
|
||||
fake_config_mod = types.ModuleType("hermes_cli.config")
|
||||
fake_config_mod.save_config = lambda c: None
|
||||
|
||||
fake_setup_mod = types.ModuleType("hermes_cli.memory_setup")
|
||||
fake_setup_mod._curses_select = lambda *a, **kw: 0
|
||||
fake_setup_mod._prompt = lambda label, default=None, secret=False: default or ""
|
||||
|
||||
fake_hermes_cli = types.ModuleType("hermes_cli")
|
||||
fake_hermes_cli.config = fake_config_mod
|
||||
fake_hermes_cli.memory_setup = fake_setup_mod
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.config", fake_config_mod)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.memory_setup", fake_setup_mod)
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._curses_select", lambda *a, **kw: 0)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._prompt", lambda label, default=None, secret=False: default or "")
|
||||
return fake_config_mod
|
||||
|
||||
|
||||
class TestParseFlags:
|
||||
|
||||
def test_mode_platform(self):
|
||||
flags = parse_flags(["--mode", "platform", "--api-key", "sk-test"])
|
||||
assert flags["mode"] == "platform"
|
||||
assert flags["api_key"] == "sk-test"
|
||||
|
||||
|
||||
def test_no_flags_returns_empty_mode(self):
|
||||
flags = parse_flags([])
|
||||
assert flags["mode"] == ""
|
||||
|
||||
def test_oss_vector_path_flag(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-vector-path", "/data/qdrant"])
|
||||
assert flags["oss_vector_path"] == "/data/qdrant"
|
||||
|
||||
|
||||
class TestBuildOSSConfig:
|
||||
|
||||
def test_openai_defaults(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
oss, env_writes = build_oss_config(flags)
|
||||
assert oss["llm"]["provider"] == "openai"
|
||||
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
|
||||
assert oss["llm"]["config"]["is_reasoning_model"] is True
|
||||
assert oss["embedder"]["provider"] == "openai"
|
||||
assert oss["embedder"]["config"]["model"] == "text-embedding-3-small"
|
||||
assert oss["vector_store"]["provider"] == "qdrant"
|
||||
assert env_writes["OPENAI_API_KEY"] == "sk-oai"
|
||||
|
||||
|
||||
def test_explicit_gpt_5_mini_is_reasoning_model(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-llm-model", "gpt-5-mini",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
|
||||
assert oss["llm"]["config"]["is_reasoning_model"] is True
|
||||
|
||||
|
||||
def test_custom_openai_model_is_not_forced_to_reasoning(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-llm-model", "gpt-5.2",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
assert oss["llm"]["config"]["model"] == "gpt-5.2"
|
||||
assert "is_reasoning_model" not in oss["llm"]["config"]
|
||||
|
||||
|
||||
def test_ollama_no_key_needed(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm", "ollama", "--oss-embedder", "ollama"])
|
||||
oss, env_writes = build_oss_config(flags)
|
||||
assert oss["llm"]["provider"] == "ollama"
|
||||
assert "model" in oss["llm"]["config"]
|
||||
assert oss["llm"]["config"]["ollama_base_url"] == "http://localhost:11434"
|
||||
assert "is_reasoning_model" not in oss["llm"]["config"]
|
||||
assert oss["embedder"]["config"]["ollama_base_url"] == "http://localhost:11434"
|
||||
assert env_writes == {}
|
||||
|
||||
def test_embedder_reuses_llm_key(self):
|
||||
"""When LLM and embedder share same provider, key written once."""
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
_, env_writes = build_oss_config(flags)
|
||||
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
|
||||
|
||||
def test_different_embedder_needs_separate_key(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss",
|
||||
"--oss-llm", "ollama",
|
||||
"--oss-embedder", "openai", "--oss-embedder-key", "sk-oai",
|
||||
])
|
||||
_, env_writes = build_oss_config(flags)
|
||||
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
|
||||
|
||||
def test_pgvector_config(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-vector", "pgvector",
|
||||
"--oss-vector-host", "db.local", "--oss-vector-port", "5433",
|
||||
"--oss-vector-user", "pg", "--oss-vector-dbname", "memdb",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
vs = oss["vector_store"]
|
||||
assert vs["provider"] == "pgvector"
|
||||
assert vs["config"]["host"] == "db.local"
|
||||
assert vs["config"]["port"] == 5433
|
||||
assert vs["config"]["user"] == "pg"
|
||||
|
||||
def test_known_dims_auto_set(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
oss, _ = build_oss_config(flags)
|
||||
dims = oss["embedder"]["config"].get("embedding_dims")
|
||||
assert dims == 1536
|
||||
|
||||
def test_custom_qdrant_path(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
"--oss-vector-path", "/data/qdrant",
|
||||
])
|
||||
oss, _ = build_oss_config(flags)
|
||||
assert oss["vector_store"]["config"]["path"] == "/data/qdrant"
|
||||
|
||||
|
||||
class TestWriteEnv:
|
||||
|
||||
def test_write_new_vars(self, tmp_path):
|
||||
env_path = tmp_path / ".env"
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
|
||||
content = env_path.read_text()
|
||||
assert "OPENAI_API_KEY=sk-test" in content
|
||||
|
||||
def test_update_existing_var(self, tmp_path):
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text("OPENAI_API_KEY=old\nOTHER=keep\n")
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "new"})
|
||||
content = env_path.read_text()
|
||||
assert "OPENAI_API_KEY=new" in content
|
||||
assert "OTHER=keep" in content
|
||||
assert "old" not in content
|
||||
|
||||
def test_preserves_non_ascii_existing_lines(self, tmp_path):
|
||||
"""Existing non-ASCII .env content must survive the read-modify-write
|
||||
as UTF-8 (the locale codec would crash/mangle it on Windows)."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
assert "PROXY_NOTE=café-zürich-完了" in content
|
||||
assert "OPENAI_API_KEY=sk-test" in content
|
||||
|
||||
def test_updates_first_key_with_bom(self, tmp_path):
|
||||
"""A Notepad-edited .env carries a BOM; the first key must still be
|
||||
matched/updated in place, not duplicated."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("OPENAI_API_KEY=old\n".encode("utf-8"))
|
||||
_write_env(env_path, {"OPENAI_API_KEY": "new"})
|
||||
content = env_path.read_text(encoding="utf-8")
|
||||
assert content.count("OPENAI_API_KEY=") == 1
|
||||
assert "OPENAI_API_KEY=new" in content
|
||||
|
||||
|
||||
class TestPromptApiKey:
|
||||
|
||||
def test_existing_key_found_behind_bom(self, tmp_path, monkeypatch):
|
||||
"""The masked-current-value lookup must see a key on the BOM'd first
|
||||
line of a Notepad-edited .env instead of prompting from scratch."""
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_bytes("OPENAI_API_KEY=sk-existing\n".encode("utf-8"))
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
prompts: list[str] = []
|
||||
|
||||
def _fake_getpass(prompt):
|
||||
prompts.append(prompt)
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.getpass.getpass", _fake_getpass)
|
||||
_prompt_api_key("OpenAI", "OPENAI_API_KEY", str(tmp_path))
|
||||
|
||||
assert len(prompts) == 1
|
||||
assert "current: ...ting" in prompts[0]
|
||||
|
||||
|
||||
class TestPostSetup:
|
||||
|
||||
def test_platform_flag_mode(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test"])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert config["memory"]["provider"] == "mem0"
|
||||
env_content = (tmp_path / ".env").read_text()
|
||||
assert "MEM0_API_KEY=sk-test" in env_content
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["mode"] == "platform"
|
||||
|
||||
|
||||
def test_selfhosted_flag_mode(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "selfhosted",
|
||||
"--host", "http://localhost:8888/", "--api-key", "admin-key",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert config["memory"]["provider"] == "mem0"
|
||||
env_content = (tmp_path / ".env").read_text()
|
||||
assert "MEM0_API_KEY=admin-key" in env_content
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["host"] == "http://localhost:8888" # trailing slash stripped
|
||||
assert mem0_json["user_id"] == "hermes-user"
|
||||
|
||||
|
||||
class TestDryRun:
|
||||
|
||||
def test_dry_run_flag_parsed(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai", "--dry-run"])
|
||||
assert flags["dry_run"] is True
|
||||
|
||||
|
||||
class TestConnectivityChecks:
|
||||
|
||||
def test_qdrant_path_writable(self, tmp_path):
|
||||
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
|
||||
assert ok is True
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Tests for Mem0 v3 API — new tool names, paginated responses, update/delete tools."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import pytest
|
||||
|
||||
import plugins.memory.mem0 as mem0_plugin
|
||||
from plugins.memory.mem0 import Mem0MemoryProvider
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
"""Fake Mem0Backend for provider-level tests."""
|
||||
|
||||
def __init__(self, search_results=None, all_results=None):
|
||||
self._search_results = search_results or []
|
||||
self._all_results = all_results or {"results": [], "count": 0}
|
||||
self.captured = []
|
||||
|
||||
def search(self, query, *, filters, top_k=10, rerank=True):
|
||||
self.captured.append(("search", query, {"filters": filters, "top_k": top_k, "rerank": rerank}))
|
||||
return self._search_results
|
||||
|
||||
def get_all(self, *, filters, page=1, page_size=100):
|
||||
self.captured.append(("get_all", {"filters": filters, "page": page, "page_size": page_size}))
|
||||
return self._all_results
|
||||
|
||||
def add(self, messages, *, user_id, agent_id, infer=False, metadata=None):
|
||||
self.captured.append((
|
||||
"add",
|
||||
messages,
|
||||
{"user_id": user_id, "agent_id": agent_id, "infer": infer, "metadata": metadata},
|
||||
))
|
||||
return {"status": "PENDING", "event_id": "evt-test-123"}
|
||||
|
||||
def update(self, memory_id, text):
|
||||
self.captured.append(("update", memory_id, text))
|
||||
return {"result": "Memory updated.", "memory_id": memory_id}
|
||||
|
||||
def delete(self, memory_id):
|
||||
self.captured.append(("delete", memory_id))
|
||||
return {"result": "Memory deleted.", "memory_id": memory_id}
|
||||
|
||||
|
||||
class TestMem0V3Tools:
|
||||
"""Test v3 tool names and response handling."""
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_search_returns_ids(self, monkeypatch):
|
||||
backend = FakeBackend(search_results=[{"id": "mem-1", "memory": "foo", "score": 0.9}])
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_search", {"query": "test"}))
|
||||
assert result["results"][0]["id"] == "mem-1"
|
||||
|
||||
|
||||
def test_add_uses_content_param(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_add", {"content": "user likes dark mode"}))
|
||||
assert len(backend.captured) == 1
|
||||
call = backend.captured[0]
|
||||
assert call[2]["infer"] is False
|
||||
assert call[2]["user_id"] == "u123"
|
||||
assert call[2]["agent_id"] == "hermes"
|
||||
assert "event_id" in result
|
||||
|
||||
|
||||
def test_old_tool_names_return_unknown(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_profile", {}))
|
||||
assert "error" in result
|
||||
result = json.loads(provider.handle_tool_call("mem0_conclude", {}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestMem0UpdateDelete:
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_update_calls_sdk(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_update", {"memory_id": "mem-1", "text": "updated fact"}
|
||||
))
|
||||
assert backend.captured[0][1] == "mem-1"
|
||||
assert backend.captured[0][2] == "updated fact"
|
||||
assert result["result"] == "Memory updated."
|
||||
assert result["memory_id"] == "mem-1"
|
||||
|
||||
|
||||
def test_delete_calls_sdk(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_delete", {"memory_id": "mem-1"}
|
||||
))
|
||||
assert backend.captured[0][1] == "mem-1"
|
||||
assert result["result"] == "Memory deleted."
|
||||
|
||||
|
||||
class TestMem0ErrorHandling:
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
|
||||
class TestMem0V3Internal:
|
||||
|
||||
def _make_provider(self, monkeypatch, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_sync_turn_explicit_kwargs(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.sync_turn("user said", "assistant replied", session_id="s1")
|
||||
provider._sync_thread.join(timeout=2)
|
||||
assert len(backend.captured) == 1
|
||||
call = backend.captured[0]
|
||||
assert call[2]["user_id"] == "u123"
|
||||
assert call[2]["agent_id"] == "hermes"
|
||||
assert call[2]["infer"] is True
|
||||
|
||||
|
||||
class TestMem0Prefetch:
|
||||
"""prefetch() must recall on the CURRENT question, synchronously.
|
||||
|
||||
The old implementation ignored its ``query`` and returned whatever a
|
||||
background ``queue_prefetch`` had warmed from the PREVIOUS turn — so the
|
||||
first turn injected nothing and later turns injected stale, off-topic
|
||||
memories. These lock the corrected behaviour.
|
||||
"""
|
||||
|
||||
def _make_provider(self, backend):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test-session")
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._backend = backend
|
||||
return provider
|
||||
|
||||
def test_prefetch_searches_current_query(self):
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "user prefers dark mode"}])
|
||||
provider = self._make_provider(backend)
|
||||
result = provider.prefetch("what theme do I like?")
|
||||
kind, query, opts = backend.captured[0]
|
||||
assert kind == "search"
|
||||
assert query == "what theme do I like?"
|
||||
assert opts["filters"] == {"user_id": "u123"}
|
||||
assert opts["top_k"] == 10
|
||||
assert opts["rerank"] is False
|
||||
assert "## Mem0 Memory" in result
|
||||
assert "user prefers dark mode" in result
|
||||
|
||||
|
||||
def test_on_turn_start_queues_current_query(self):
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
|
||||
provider = self._make_provider(backend)
|
||||
provider.on_turn_start(1, "where do I live?")
|
||||
provider._prefetch_thread.join(timeout=1)
|
||||
result = provider.prefetch("where do I live?")
|
||||
assert "lives in Berlin" in result
|
||||
assert len([c for c in backend.captured if c[0] == "search"]) == 1
|
||||
|
||||
def test_slow_prefetch_returns_quickly(self, monkeypatch):
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
search_returned = threading.Event()
|
||||
|
||||
class SlowBackend(FakeBackend):
|
||||
def search(self, query, *, filters, top_k=10, rerank=True):
|
||||
entered.set()
|
||||
try:
|
||||
release.wait(30)
|
||||
return super().search(
|
||||
query, filters=filters, top_k=top_k, rerank=rerank
|
||||
)
|
||||
finally:
|
||||
search_returned.set()
|
||||
|
||||
monkeypatch.setattr(mem0_plugin, "_PREFETCH_WAIT_SECS", 0.01)
|
||||
provider = self._make_provider(
|
||||
SlowBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
|
||||
)
|
||||
# DETERMINISTIC non-blocking witness — replaces `assert elapsed < 0.1`.
|
||||
#
|
||||
# The old form slept 0.2s in the backend and asserted prefetch returned
|
||||
# in under 0.1s. That makes the OS scheduler part of the assertion: on
|
||||
# a loaded box thread startup alone can eat the 100ms budget, so the
|
||||
# inequality flips with nothing wrong in the code under test. Observed
|
||||
# failing in a full-directory run of tests/plugins/memory.
|
||||
#
|
||||
# The real contract is that prefetch gives up on the slow backend
|
||||
# instead of waiting for it. Assert it directly: the backend search is
|
||||
# STILL PARKED (release unset, so `search_returned` cannot be set). If
|
||||
# prefetch ever waited for the backend, the search would have returned
|
||||
# first and this fails. No wall-clock constant.
|
||||
assert provider.prefetch("where do I live?") == ""
|
||||
assert entered.wait(30), "prefetch never reached the backend"
|
||||
assert not search_returned.is_set(), (
|
||||
"prefetch blocked on the slow backend: the backend search had "
|
||||
"already returned by the time prefetch did"
|
||||
)
|
||||
|
||||
release.set()
|
||||
provider._prefetch_thread.join(timeout=30)
|
||||
assert "lives in Berlin" in provider.prefetch("where do I live?")
|
||||
|
||||
|
||||
def test_queue_prefetch_fires_no_search(self):
|
||||
# prefetch is synchronous now, so the post-turn warm is redundant and
|
||||
# must not fire a wasted backend search.
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "x"}])
|
||||
provider = self._make_provider(backend)
|
||||
provider.queue_prefetch("previous turn text")
|
||||
assert backend.captured == []
|
||||
|
||||
|
||||
class TestMem0V3Config:
|
||||
|
||||
def test_tool_schemas_four_tools(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
names = [s["name"] for s in schemas]
|
||||
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
|
||||
|
||||
def test_system_prompt_new_tool_names(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
block = provider.system_prompt_block()
|
||||
assert "mem0_search" in block
|
||||
assert "mem0_add" in block
|
||||
assert "mem0_update" in block
|
||||
assert "mem0_delete" in block
|
||||
assert "mem0_list" not in block
|
||||
assert "mem0_profile" not in block
|
||||
assert "mem0_conclude" not in block
|
||||
|
||||
|
||||
class TestMem0ModeSwitch:
|
||||
|
||||
def test_default_mode_is_platform(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test")
|
||||
assert provider._mode == "platform"
|
||||
|
||||
def test_missing_mode_key_defaults_platform(self, monkeypatch, tmp_path):
|
||||
"""Backward compat: old mem0.json without mode key works."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_path = tmp_path / "mem0.json"
|
||||
config_path.write_text('{"user_id": "old-user"}')
|
||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
||||
provider = Mem0MemoryProvider()
|
||||
provider.initialize("test")
|
||||
assert provider._mode == "platform"
|
||||
assert provider._user_id == "old-user"
|
||||
|
||||
def test_is_available_platform_needs_key(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
provider = Mem0MemoryProvider()
|
||||
assert provider.is_available() is False
|
||||
|
||||
|
||||
class TestMem0UserIdResolution:
|
||||
"""user_id resolution: configured override > gateway-native id > placeholder.
|
||||
|
||||
Same human across CLI / Telegram / Discord / Slack / etc. should map to
|
||||
the same memory store when MEM0_USER_ID is set, and only fall back to the
|
||||
gateway-native id when it isn't.
|
||||
"""
|
||||
|
||||
def _provider(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
||||
provider = Mem0MemoryProvider()
|
||||
# Skip backend instantiation — we only care about identity resolution.
|
||||
provider._create_backend = lambda: None # type: ignore[method-assign]
|
||||
return provider
|
||||
|
||||
def test_env_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MEM0_USER_ID", "ryan@example.com")
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "ryan@example.com"
|
||||
|
||||
def test_file_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
(tmp_path / "mem0.json").write_text('{"user_id": "ryan@example.com"}')
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "ryan@example.com"
|
||||
|
||||
def test_unset_falls_back_to_gateway_native_id(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "123456789"
|
||||
|
||||
|
||||
def test_legacy_placeholder_in_config_does_not_override_kwargs(self, monkeypatch, tmp_path):
|
||||
# Setup wizard historically wrote {"user_id": "hermes-user"} as the
|
||||
# suggested default. Treat that placeholder as unset so users on
|
||||
# gateways still get gateway-native ids — not silent collisions.
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
(tmp_path / "mem0.json").write_text('{"user_id": "hermes-user"}')
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test", user_id="123456789", platform="telegram")
|
||||
assert provider._user_id == "123456789"
|
||||
|
||||
|
||||
class TestMem0WriteMetadata:
|
||||
"""Writes carry metadata.channel so per-channel filtered views are possible
|
||||
without coupling identity to the channel.
|
||||
"""
|
||||
|
||||
def _make_provider(self, channel: str = "cli"):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "u123"
|
||||
provider._agent_id = "hermes"
|
||||
provider._channel = channel
|
||||
provider._backend = FakeBackend()
|
||||
return provider
|
||||
|
||||
|
||||
class _SentinelBackend:
|
||||
def __init__(self, *args):
|
||||
self.args = args
|
||||
|
||||
|
||||
class TestCreateBackendRouting:
|
||||
"""_create_backend() must pick the backend matching the configured mode/host."""
|
||||
|
||||
def _provider(self, monkeypatch, *, mode="platform", api_key="k", host=""):
|
||||
# Neutralize lazy-install so the routing decision is all we exercise.
|
||||
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None, raising=False)
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._mode = mode
|
||||
provider._api_key = api_key
|
||||
provider._host = host
|
||||
provider._config = {"oss": {"vector_store": {"provider": "qdrant"}}}
|
||||
return provider
|
||||
|
||||
def test_routes_to_selfhosted_when_host_set(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class SH(_SentinelBackend):
|
||||
def __init__(self, api_key, host):
|
||||
captured["args"] = (api_key, host)
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.SelfHostedBackend", SH)
|
||||
provider = self._provider(monkeypatch, host="http://sh:8888", api_key="adminkey")
|
||||
backend = provider._create_backend()
|
||||
assert isinstance(backend, SH)
|
||||
assert captured["args"] == ("adminkey", "http://sh:8888")
|
||||
|
||||
|
||||
def test_oss_mode_takes_precedence_over_host(self, monkeypatch):
|
||||
class OB(_SentinelBackend):
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
|
||||
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
|
||||
assert isinstance(provider._create_backend(), OB)
|
||||
|
||||
def test_prompt_label_matches_routing_when_oss_and_host_both_set(self, monkeypatch):
|
||||
# system_prompt_block must mirror _create_backend precedence: with both
|
||||
# mode=oss and host set, OSS wins the routing, so the prompt must label
|
||||
# OSS — not "self-hosted (HTTP API)". Guards the prompt-vs-routing lie.
|
||||
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
|
||||
provider._user_id = "test"
|
||||
block = provider.system_prompt_block()
|
||||
assert "OSS" in block
|
||||
assert "HTTP API" not in block
|
||||
|
||||
|
||||
class TestSelfHostedConfig:
|
||||
"""Config plumbing for self-hosted (MEM0_HOST env + is_available)."""
|
||||
|
||||
def test_load_config_reads_mem0_host_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
|
||||
assert mem0_plugin._load_config()["host"] == "http://localhost:8888"
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Regression tests: supermemory + mem0 memory providers must lazy-install
|
||||
their SDKs like honcho/hindsight.
|
||||
|
||||
Both providers ship a third-party SDK (``supermemory`` / ``mem0ai``) that is
|
||||
NOT a core dependency. Before this fix they imported the SDK directly with no
|
||||
``tools.lazy_deps.ensure()`` preflight and had no ``LAZY_DEPS`` allowlist
|
||||
entry. On the published Docker image the agent venv is sealed
|
||||
(``HERMES_DISABLE_LAZY_INSTALLS=1``) and lazy installs are redirected to a
|
||||
writable durable target (``HERMES_LAZY_INSTALL_TARGET``). honcho/hindsight
|
||||
route through ``ensure()`` and therefore install fine on a hosted instance;
|
||||
supermemory/mem0 never called it, so the SDK was never installed there and
|
||||
the provider silently reported itself unavailable.
|
||||
|
||||
These tests pin the contract:
|
||||
|
||||
1. Both features are in the ``LAZY_DEPS`` allowlist (without an entry,
|
||||
``ensure()`` raises ``FeatureUnavailable`` — the original silent-dark bug).
|
||||
2. Each provider's SDK-import chokepoint actually calls ``ensure(<feature>)``.
|
||||
3. supermemory's ``is_available()`` no longer gates on the SDK being
|
||||
importable (the chicken-and-egg trap that stopped the provider loading at
|
||||
all on a sealed venv, so ``initialize()``/``ensure()`` never ran).
|
||||
4. The real sealed-venv durable-target gate accepts the new features (the
|
||||
exact hosted-Fly condition the user hit).
|
||||
|
||||
The pip subprocess is never actually run — ``_venv_pip_install`` /
|
||||
``_is_satisfied`` are stubbed so we exercise the real ``ensure()`` control
|
||||
flow without touching PyPI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.lazy_deps as ld
|
||||
|
||||
|
||||
MEMORY_FEATURES = ("memory.supermemory", "memory.mem0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Allowlist contract — the core regression.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowlistEntries:
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_feature_is_allowlisted(self, feature):
|
||||
# Without an allowlist entry, ensure() raises FeatureUnavailable with
|
||||
# "not in LAZY_DEPS" — which is exactly why the SDK never installed on
|
||||
# a hosted instance before this fix.
|
||||
assert feature in ld.LAZY_DEPS, (
|
||||
f"{feature!r} missing from LAZY_DEPS — its SDK can never "
|
||||
f"lazy-install on a sealed Docker venv."
|
||||
)
|
||||
|
||||
|
||||
def test_supermemory_spec_package(self):
|
||||
specs = ld.LAZY_DEPS["memory.supermemory"]
|
||||
assert any(ld._pkg_name_from_spec(s) == "supermemory" for s in specs)
|
||||
|
||||
def test_mem0_spec_package(self):
|
||||
# mem0's pip package is ``mem0ai`` (imports as ``mem0``).
|
||||
specs = ld.LAZY_DEPS["memory.mem0"]
|
||||
assert any(ld._pkg_name_from_spec(s) == "mem0ai" for s in specs)
|
||||
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_unknown_feature_would_raise_without_entry(self, feature, monkeypatch):
|
||||
# Demonstrate the failure mode the allowlist entry prevents: a feature
|
||||
# NOT in LAZY_DEPS raises rather than installing.
|
||||
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
|
||||
with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"):
|
||||
ld.ensure(feature + ".typo", prompt=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Import sites call ensure().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSupermemoryEnsureCalled:
|
||||
def test_client_construction_calls_ensure(self, monkeypatch):
|
||||
"""_SupermemoryClient.__init__ must call ensure('memory.supermemory')
|
||||
before importing the SDK."""
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
ld, "ensure",
|
||||
lambda feature, **kw: calls.append((feature, kw)),
|
||||
)
|
||||
|
||||
# Stub the SDK so construction doesn't need the real package. The
|
||||
# client does ``from supermemory import Supermemory`` right after
|
||||
# ensure(); inject a fake module.
|
||||
import sys
|
||||
import types
|
||||
|
||||
fake = types.ModuleType("supermemory")
|
||||
fake.Supermemory = lambda **kw: object()
|
||||
monkeypatch.setitem(sys.modules, "supermemory", fake)
|
||||
|
||||
_SupermemoryClient(api_key="k", timeout=5.0, container_tag="hermes")
|
||||
|
||||
assert ("memory.supermemory", {"prompt": False}) in calls, (
|
||||
"supermemory client did not call ensure('memory.supermemory', "
|
||||
f"prompt=False); calls={calls}"
|
||||
)
|
||||
|
||||
|
||||
class TestMem0EnsureCalled:
|
||||
def test_create_backend_calls_ensure(self, monkeypatch):
|
||||
"""SupermemoryMemoryProvider-style mem0 provider must call
|
||||
ensure('memory.mem0') in _create_backend before importing the SDK."""
|
||||
from plugins.memory.mem0 import Mem0MemoryProvider
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
ld, "ensure",
|
||||
lambda feature, **kw: calls.append((feature, kw)),
|
||||
)
|
||||
|
||||
prov = Mem0MemoryProvider()
|
||||
# Platform mode is the default; force a known mode and stub the backend
|
||||
# import so we isolate the ensure() call.
|
||||
prov._mode = "platform"
|
||||
prov._api_key = "k"
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
fake = types.ModuleType("mem0")
|
||||
fake.MemoryClient = lambda **kw: object()
|
||||
fake.Memory = object
|
||||
monkeypatch.setitem(sys.modules, "mem0", fake)
|
||||
# _backend imports ``from mem0 import MemoryClient`` lazily inside
|
||||
# PlatformBackend.__init__, so the fake module satisfies it.
|
||||
|
||||
prov._create_backend()
|
||||
|
||||
assert ("memory.mem0", {"prompt": False}) in calls, (
|
||||
f"mem0 _create_backend did not call ensure('memory.mem0', "
|
||||
f"prompt=False); calls={calls}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. supermemory is_available() chicken-and-egg fix.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSupermemoryIsAvailable:
|
||||
def test_available_with_key_even_when_sdk_absent(self, monkeypatch):
|
||||
"""With the key set but the SDK not importable, is_available() must
|
||||
still return True — otherwise the provider never loads on a sealed
|
||||
venv and ensure() (which installs the SDK) never runs."""
|
||||
from plugins.memory.supermemory import SupermemoryMemoryProvider
|
||||
import builtins
|
||||
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "sk-test")
|
||||
|
||||
# Make any attempt to import the SDK fail, simulating the
|
||||
# not-yet-installed sealed-venv state.
|
||||
real_import = builtins.__import__
|
||||
|
||||
def _no_supermemory(name, *args, **kwargs):
|
||||
if name == "supermemory" or name.startswith("supermemory."):
|
||||
raise ImportError("No module named 'supermemory'")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _no_supermemory)
|
||||
|
||||
prov = SupermemoryMemoryProvider()
|
||||
assert prov.is_available() is True
|
||||
|
||||
def test_unavailable_without_key(self, monkeypatch):
|
||||
from plugins.memory.supermemory import SupermemoryMemoryProvider
|
||||
|
||||
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
|
||||
prov = SupermemoryMemoryProvider()
|
||||
assert prov.is_available() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Real sealed-venv durable-target gate accepts the new features.
|
||||
#
|
||||
# This is the exact hosted-Fly condition: HERMES_DISABLE_LAZY_INSTALLS=1 seals
|
||||
# the venv, but HERMES_LAZY_INSTALL_TARGET redirects installs to a writable
|
||||
# durable dir, so installs are still ALLOWED. We exercise the real
|
||||
# _allow_lazy_installs() + ensure() flow end-to-end with only the pip
|
||||
# subprocess stubbed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSealedVenvDurableTarget:
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_ensure_installs_into_durable_target_on_sealed_venv(
|
||||
self, feature, monkeypatch, tmp_path
|
||||
):
|
||||
# Sealed venv + durable target = the published Docker image config.
|
||||
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
||||
monkeypatch.setenv("HERMES_LAZY_INSTALL_TARGET", str(tmp_path / "lazy"))
|
||||
# config.yaml kill-switch left at default (allow).
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {"allow_lazy_installs": True}},
|
||||
)
|
||||
|
||||
# Real gate must permit installs because a durable target is set.
|
||||
assert ld._allow_lazy_installs() is True, (
|
||||
"sealed venv WITH a durable target must allow installs — this is "
|
||||
"the path honcho/hindsight use on hosted Fly instances"
|
||||
)
|
||||
|
||||
# Drive ensure(): missing first, satisfied after the (stubbed) install.
|
||||
states = iter([False, True])
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states))
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_install(specs, **kw):
|
||||
captured["specs"] = specs
|
||||
captured["target_env"] = os.environ.get("HERMES_LAZY_INSTALL_TARGET")
|
||||
return ld._InstallResult(True, "ok", "")
|
||||
|
||||
monkeypatch.setattr(ld, "_venv_pip_install", fake_install)
|
||||
|
||||
ld.ensure(feature, prompt=False) # must not raise
|
||||
|
||||
assert captured.get("specs") == ld.LAZY_DEPS[feature]
|
||||
assert captured.get("target_env"), (
|
||||
"install ran without the durable target env set"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_sealed_venv_without_target_blocks(self, feature, monkeypatch):
|
||||
# Sealed venv and NO durable target → installs blocked (can't mutate
|
||||
# the sealed venv). Belt-and-suspenders: confirms the gate still
|
||||
# protects the seal for these features.
|
||||
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
|
||||
monkeypatch.delenv("HERMES_LAZY_INSTALL_TARGET", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"security": {"allow_lazy_installs": True}},
|
||||
)
|
||||
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
|
||||
|
||||
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
|
||||
ld.ensure(feature, prompt=False)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""OpenViking endpoint always-blocked floor."""
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.openviking import (
|
||||
_OpenVikingEndpointError,
|
||||
_local_openviking_bind,
|
||||
_normalize_openviking_url,
|
||||
_openviking_endpoint_is_always_blocked,
|
||||
)
|
||||
|
||||
|
||||
def test_openviking_blocks_metadata_endpoint():
|
||||
with pytest.raises(_OpenVikingEndpointError, match="blocked metadata address"):
|
||||
_normalize_openviking_url("http://169.254.169.254/")
|
||||
|
||||
|
||||
def test_openviking_keeps_default_loopback():
|
||||
assert _normalize_openviking_url("http://127.0.0.1:1933") == "http://127.0.0.1:1933"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["localhost", "127.0.0.1"])
|
||||
def test_openviking_bare_loopback_health_and_autostart_use_same_default_port(host):
|
||||
endpoint = _normalize_openviking_url(host)
|
||||
|
||||
assert endpoint == f"http://{host}:1933"
|
||||
assert _local_openviking_bind(endpoint) == (host, 1933)
|
||||
|
||||
|
||||
def test_openviking_explicit_loopback_url_preserves_implicit_http_port():
|
||||
assert _normalize_openviking_url("http://localhost") == "http://localhost"
|
||||
|
||||
|
||||
def test_openviking_blocks_ecs_metadata_hostname():
|
||||
with pytest.raises(_OpenVikingEndpointError, match="blocked metadata address"):
|
||||
_normalize_openviking_url("http://metadata.google.internal/computeMetadata/v1/")
|
||||
|
||||
|
||||
def test_openviking_rejects_endpoint_credentials_and_query():
|
||||
with pytest.raises(_OpenVikingEndpointError, match="cannot contain user info"):
|
||||
_normalize_openviking_url("https://user:secret@example.com?api_key=secret")
|
||||
|
||||
|
||||
def test_openviking_validates_shorthand_ipv6_port():
|
||||
assert _normalize_openviking_url("::1:1934") == "http://[::1]:1934"
|
||||
with pytest.raises(_OpenVikingEndpointError, match="Port could not be cast"):
|
||||
_normalize_openviking_url("::1:not-a-port")
|
||||
|
||||
|
||||
def test_openviking_caches_safety_check_for_unchanged_endpoint(monkeypatch):
|
||||
import tools.url_safety as url_safety
|
||||
|
||||
calls = []
|
||||
_openviking_endpoint_is_always_blocked.cache_clear()
|
||||
monkeypatch.setattr(
|
||||
url_safety,
|
||||
"is_always_blocked_url",
|
||||
lambda value: calls.append(value) or False,
|
||||
)
|
||||
|
||||
assert _normalize_openviking_url("https://openviking.example.test") == (
|
||||
"https://openviking.example.test"
|
||||
)
|
||||
assert _normalize_openviking_url("https://openviking.example.test") == (
|
||||
"https://openviking.example.test"
|
||||
)
|
||||
assert calls == ["https://openviking.example.test"]
|
||||
_openviking_endpoint_is_always_blocked.cache_clear()
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Optional peer identity must agree across setup, requests and memory writes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import plugins.memory.openviking as ov
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
for key in (*ov._OPENVIKING_ENV_KEYS, "OPENVIKING_CLI_CONFIG_FILE"):
|
||||
# Track absent keys too, so setup's direct environment writes are undone.
|
||||
monkeypatch.setenv(key, "")
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["env", "yaml", "actor_peer_id", "agent_id"])
|
||||
@pytest.mark.parametrize("peer", ["", "hermes", "work-assistant"])
|
||||
def test_configured_peer_routing_is_preserved(tmp_path, monkeypatch, source, peer):
|
||||
config = {}
|
||||
if source == "env":
|
||||
monkeypatch.setenv("OPENVIKING_AGENT", peer)
|
||||
elif source == "yaml":
|
||||
config["agent"] = peer
|
||||
else:
|
||||
path = tmp_path / "ovcli.conf"
|
||||
path.write_text(
|
||||
json.dumps({"url": "http://localhost:1933", source: peer}), encoding="utf-8"
|
||||
)
|
||||
config = {"use_ovcli_config": True, "ovcli_config_path": str(path)}
|
||||
|
||||
settings = ov._resolve_connection_settings(config)
|
||||
client = ov._VikingClient("http://localhost:1933", agent=settings["agent"])
|
||||
monkeypatch.setattr(client, "get", lambda *a, **kw: {"result": {"user": "alice"}})
|
||||
provider = ov.OpenVikingMemoryProvider()
|
||||
uri = provider._build_memory_uri("preferences", client=client)
|
||||
|
||||
assert settings["agent"] == peer
|
||||
assert client._headers().get("X-OpenViking-Actor-Peer", "") == peer
|
||||
prefix = f"peers/{peer}/" if peer else ""
|
||||
assert uri.startswith(f"viking://user/alice/{prefix}memories/preferences/mem_")
|
||||
|
||||
|
||||
def test_unconfigured_client_and_schema_do_not_supply_a_peer():
|
||||
settings = ov._resolve_connection_settings({})
|
||||
client = ov._VikingClient("http://localhost:1933")
|
||||
schema = {
|
||||
field["key"]: field
|
||||
for field in ov.OpenVikingMemoryProvider().get_config_schema()
|
||||
}
|
||||
|
||||
assert settings["agent"] == ""
|
||||
assert schema["agent"]["default"] == ""
|
||||
assert "X-OpenViking-Actor-Peer" not in client._headers()
|
||||
assert "X-OpenViking-Actor-Peer" not in client._multipart_headers()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("peer", ["", "hermes"])
|
||||
def test_linked_profile_status_only_shows_a_configured_peer(tmp_path, peer):
|
||||
path = tmp_path / "ovcli.conf"
|
||||
path.write_text(
|
||||
json.dumps({"url": "http://localhost:1933", "actor_peer_id": peer}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
display = ov.OpenVikingMemoryProvider().get_status_config({
|
||||
"use_ovcli_config": True,
|
||||
"ovcli_config_path": str(path),
|
||||
})
|
||||
|
||||
if peer:
|
||||
assert display["agent"] == peer
|
||||
else:
|
||||
assert "agent" not in display
|
||||
|
||||
|
||||
@pytest.mark.parametrize("peer", ["", "hermes"])
|
||||
def test_memory_uri_uses_captured_peer_even_when_empty(monkeypatch, peer):
|
||||
client = ov._VikingClient("http://localhost:1933", agent=peer)
|
||||
monkeypatch.setattr(client, "get", lambda *a, **kw: {"result": {"user": "alice"}})
|
||||
provider = ov.OpenVikingMemoryProvider()
|
||||
provider._agent = "later-peer"
|
||||
|
||||
uri = provider._build_memory_uri("preferences", client=client)
|
||||
|
||||
prefix = f"peers/{peer}/" if peer else ""
|
||||
assert uri.startswith(f"viking://user/alice/{prefix}memories/preferences/mem_")
|
||||
assert "later-peer" not in uri
|
||||
|
||||
|
||||
@pytest.mark.parametrize("save_to_store", [False, True])
|
||||
@pytest.mark.parametrize("credential", ["dev", "user", "root", "service"])
|
||||
@pytest.mark.parametrize("stale_env", [False, True])
|
||||
def test_new_setup_does_not_ask_for_or_save_peer(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
save_to_store,
|
||||
credential,
|
||||
stale_env,
|
||||
):
|
||||
from hermes_cli import memory_setup
|
||||
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
(home / ".env").write_text(
|
||||
"OPENVIKING_AGENT=old-peer\nOTHER_KEY=keep\n", encoding="utf-8"
|
||||
)
|
||||
config = {"memory": {"openviking": {"agent": "old-peer", "recall_limit": 9}}}
|
||||
if stale_env:
|
||||
for key in ov._OPENVIKING_ENV_KEYS:
|
||||
monkeypatch.setenv(
|
||||
key, "old-peer" if key == "OPENVIKING_AGENT" else "old-value"
|
||||
)
|
||||
monkeypatch.setenv("OTHER_KEY", "keep")
|
||||
validations = []
|
||||
|
||||
def validate(values, **kwargs):
|
||||
validations.append(dict(values))
|
||||
role = (
|
||||
"root"
|
||||
if credential == "root"
|
||||
else "user"
|
||||
if values.get("api_key")
|
||||
else None
|
||||
)
|
||||
return True, "", role
|
||||
|
||||
def prompt(label, default=None, secret=False):
|
||||
values = {
|
||||
"OpenViking server URL": "http://localhost:1933",
|
||||
"OpenViking user API key": "test-user-key",
|
||||
"OpenViking root API key": "test-root-key",
|
||||
"OpenViking API key": "test-service-key",
|
||||
"OpenViking account": "account",
|
||||
"OpenViking user": "alice",
|
||||
"OpenViking profile name": "personal",
|
||||
}
|
||||
assert label in values, f"Unexpected setup question: {label}"
|
||||
return values[label]
|
||||
|
||||
def select(title, options, **kwargs):
|
||||
choices = {
|
||||
" OpenViking connection": 0 if credential == "service" else 1,
|
||||
" OpenViking credential": {"dev": 2, "user": 0, "root": 1}.get(
|
||||
credential, 0
|
||||
),
|
||||
" Save OpenViking config": int(save_to_store),
|
||||
}
|
||||
assert title in choices, f"Unexpected setup menu: {title}"
|
||||
return choices[title]
|
||||
|
||||
monkeypatch.setattr(memory_setup, "_prompt", prompt)
|
||||
monkeypatch.setattr(memory_setup, "_curses_select", select)
|
||||
monkeypatch.setattr(ov, "_validate_openviking_reachability", lambda *a: (True, ""))
|
||||
monkeypatch.setattr(ov, "_validate_openviking_setup_values", validate)
|
||||
|
||||
ov.OpenVikingMemoryProvider().post_setup(str(home), config)
|
||||
|
||||
assert validations
|
||||
assert all(values["agent"] == "" for values in validations)
|
||||
assert "OPENVIKING_AGENT" not in (home / ".env").read_text(encoding="utf-8")
|
||||
assert "OTHER_KEY=keep" in (home / ".env").read_text(encoding="utf-8")
|
||||
saved_config = ov._load_hermes_openviking_config()
|
||||
assert saved_config["recall_limit"] == 9
|
||||
settings = ov._resolve_connection_settings(saved_config)
|
||||
assert settings["agent"] == ""
|
||||
assert settings == {
|
||||
key: validations[-1][key]
|
||||
for key in ("endpoint", "api_key", "account", "user", "agent")
|
||||
}
|
||||
assert "OPENVIKING_AGENT" not in os.environ
|
||||
assert os.environ["OTHER_KEY"] == "keep"
|
||||
if save_to_store:
|
||||
saved = json.loads(
|
||||
Path(saved_config["ovcli_config_path"]).read_text(encoding="utf-8")
|
||||
)
|
||||
assert "actor_peer_id" not in saved
|
||||
assert "agent_id" not in saved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("peer", ["", "work-assistant"])
|
||||
def test_hermes_only_save_uses_the_same_clean_values_in_file_and_process(
|
||||
tmp_path, peer
|
||||
):
|
||||
from dotenv import dotenv_values
|
||||
|
||||
env_path = tmp_path / ".env"
|
||||
ov._save_hermes_only_config(
|
||||
config={"memory": {}},
|
||||
provider_config={},
|
||||
env_path=env_path,
|
||||
values={
|
||||
"endpoint": "http://localhost:29333",
|
||||
"api_key": "test\r\n-key\x00",
|
||||
"agent": peer,
|
||||
},
|
||||
)
|
||||
|
||||
expected = {
|
||||
"OPENVIKING_ENDPOINT": "http://localhost:29333",
|
||||
"OPENVIKING_API_KEY": "test-key",
|
||||
}
|
||||
if peer:
|
||||
expected["OPENVIKING_AGENT"] = peer
|
||||
assert dict(dotenv_values(env_path)) == expected
|
||||
assert {
|
||||
key: os.environ[key] for key in ov._OPENVIKING_ENV_KEYS if key in os.environ
|
||||
} == expected
|
||||
|
||||
|
||||
def test_hermes_only_save_failure_leaves_process_environment_unchanged(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
for key in ov._OPENVIKING_ENV_KEYS:
|
||||
monkeypatch.setenv(key, "old-value")
|
||||
|
||||
def fail_write(*args, **kwargs):
|
||||
raise OSError("test write failure")
|
||||
|
||||
monkeypatch.setattr(ov, "_write_env_vars", fail_write)
|
||||
with pytest.raises(OSError, match="test write failure"):
|
||||
ov._save_hermes_only_config(
|
||||
config={"memory": {}},
|
||||
provider_config={},
|
||||
env_path=tmp_path / ".env",
|
||||
values={"endpoint": "http://localhost:29333", "api_key": "test-key"},
|
||||
)
|
||||
|
||||
assert all(os.environ[key] == "old-value" for key in ov._OPENVIKING_ENV_KEYS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("peer", ["", "hermes"])
|
||||
def test_wire_requests_keep_writes_and_session_messages_in_the_selected_scope(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
peer,
|
||||
):
|
||||
records = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def respond(self, payload):
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self.respond({"status": "ok", "healthy": True, "version": "test"})
|
||||
elif self.path == "/api/v1/system/status":
|
||||
self.respond({"result": {"user": "alice"}})
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
records.append((self.path, dict(self.headers), payload))
|
||||
self.respond({"status": "ok", "result": {"written_bytes": 10}})
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir()
|
||||
provider_config = {"endpoint": f"http://127.0.0.1:{server.server_port}"}
|
||||
if peer:
|
||||
provider_config["agent"] = peer
|
||||
(home / "config.yaml").write_text(
|
||||
yaml.safe_dump({
|
||||
"memory": {"provider": "openviking", "openviking": provider_config}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
provider = ov.OpenVikingMemoryProvider()
|
||||
try:
|
||||
provider.initialize("peer-test", hermes_home=str(home))
|
||||
assert provider._client is not None
|
||||
result = json.loads(
|
||||
provider.handle_tool_call("viking_remember", {"content": "I like tea"})
|
||||
)
|
||||
assert result["status"] == "submitted"
|
||||
provider.on_memory_write("add", "user", "I like coffee")
|
||||
provider.sync_turn("hello", "hi", session_id="peer-test")
|
||||
assert provider._drain_writers("peer-test", timeout=5.0)
|
||||
provider.sync_turn(
|
||||
"next",
|
||||
"reply",
|
||||
session_id="peer-test",
|
||||
messages=[
|
||||
{"role": "user", "content": "next"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
],
|
||||
)
|
||||
assert provider._drain_writers("peer-test", timeout=5.0)
|
||||
provider.on_session_end([])
|
||||
finally:
|
||||
provider.shutdown()
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
assert records
|
||||
for _path, headers, _payload in records:
|
||||
if peer:
|
||||
assert headers["X-OpenViking-Actor-Peer"] == peer
|
||||
else:
|
||||
assert "X-OpenViking-Actor-Peer" not in headers
|
||||
writes = [
|
||||
payload for path, _, payload in records if path == "/api/v1/content/write"
|
||||
]
|
||||
prefix = f"peers/{peer}/" if peer else ""
|
||||
assert {write["content"] for write in writes} == {"I like coffee"}
|
||||
assert all(
|
||||
write["uri"].startswith(f"viking://user/alice/{prefix}memories/")
|
||||
for write in writes
|
||||
)
|
||||
remember_messages = [
|
||||
(path, payload)
|
||||
for path, _, payload in records
|
||||
if path.startswith("/api/v1/sessions/hermes-remember-")
|
||||
and path.endswith("/messages")
|
||||
]
|
||||
assert len(remember_messages) == 1
|
||||
remember_path, remember_message = remember_messages[0]
|
||||
remember_session = remember_path.removesuffix("/messages")
|
||||
assert remember_message == {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": "I like tea"}],
|
||||
}
|
||||
assert any(path == f"{remember_session}/commit" for path, _, _ in records)
|
||||
batches = [
|
||||
payload["messages"]
|
||||
for path, _, payload in records
|
||||
if path.endswith("/messages/batch")
|
||||
]
|
||||
assert len(batches) == 2
|
||||
for batch in batches:
|
||||
assert "peer_id" not in batch[0]
|
||||
if peer:
|
||||
assert batch[1]["peer_id"] == peer
|
||||
else:
|
||||
assert "peer_id" not in batch[1]
|
||||
assert any(path.endswith("/commit") for path, _, _ in records)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
"""Tests for OpenViking memory-provider shutdown teardown.
|
||||
|
||||
The runtime-autostart waiter is a tracked ``daemon=True`` thread that blocks
|
||||
on network health probes. If ``shutdown()`` doesn't join it (and the waiter
|
||||
doesn't bail on the shutdown flag), it can be left alive at interpreter exit,
|
||||
which crashes CPython with SIGABRT at ``Py_FinalizeEx``. These tests assert
|
||||
the waiter short-circuits on shutdown and that ``shutdown()`` waits for the
|
||||
runtime-start thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import plugins.memory.openviking as openviking_module
|
||||
from plugins.memory.openviking import OpenVikingMemoryProvider
|
||||
|
||||
|
||||
def test_wait_for_health_short_circuits_on_should_stop():
|
||||
"""The health waiter returns False without probing when should_stop is set,
|
||||
so the daemon thread running it can be join()ed promptly at shutdown."""
|
||||
probes: list[str] = []
|
||||
|
||||
def _reach(endpoint):
|
||||
probes.append(endpoint)
|
||||
return (False, "down")
|
||||
|
||||
with patch.object(
|
||||
openviking_module, "_validate_openviking_reachability", _reach
|
||||
):
|
||||
result = openviking_module._wait_for_openviking_health(
|
||||
"http://example.invalid",
|
||||
timeout_seconds=60.0,
|
||||
should_stop=lambda: True,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
assert probes == [] # bailed before the first network probe
|
||||
|
||||
|
||||
def test_shutdown_waits_for_runtime_start_thread():
|
||||
"""shutdown() must join the runtime-autostart waiter thread.
|
||||
|
||||
The fake waiter does post-stop work (a short sleep) once it observes the
|
||||
shutdown flag. If shutdown() joins it, that work has completed by the time
|
||||
shutdown() returns; without the join, shutdown() returns early and the
|
||||
thread is still running (the SIGABRT-at-exit failure mode).
|
||||
"""
|
||||
provider = OpenVikingMemoryProvider()
|
||||
started = threading.Event()
|
||||
finished = threading.Event()
|
||||
|
||||
def _runtime():
|
||||
started.set()
|
||||
while not provider._shutting_down:
|
||||
time.sleep(0.01)
|
||||
time.sleep(0.2) # work that must finish during shutdown's join
|
||||
finished.set()
|
||||
|
||||
t = threading.Thread(target=_runtime, daemon=True, name="openviking-runtime-start")
|
||||
provider._runtime_start_thread = t
|
||||
t.start()
|
||||
assert started.wait(2.0)
|
||||
|
||||
provider.shutdown()
|
||||
|
||||
assert finished.is_set()
|
||||
assert not t.is_alive()
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import agent.file_safety as fs
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.memory.retaindb as retaindb
|
||||
from plugins.memory.retaindb import RetainDBMemoryProvider
|
||||
|
||||
|
||||
def test_write_queue_closes_owner_connection(tmp_path):
|
||||
queue = retaindb._WriteQueue(object(), tmp_path / "retaindb.db")
|
||||
owner_conn = queue._local.conn
|
||||
worker = retaindb.threading.Thread(target=queue._get_conn)
|
||||
worker.start()
|
||||
worker.join()
|
||||
queue.shutdown()
|
||||
assert not queue._connections
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
owner_conn.execute("SELECT 1")
|
||||
|
||||
|
||||
def test_write_queue_ignores_enqueue_after_shutdown(tmp_path):
|
||||
queue = retaindb._WriteQueue(object(), tmp_path / "retaindb.db")
|
||||
queue.shutdown()
|
||||
|
||||
queue.enqueue("user", "session", [])
|
||||
|
||||
assert not queue._connections
|
||||
|
||||
|
||||
def test_prefetch_does_not_spawn_when_previous_batch_is_alive(monkeypatch):
|
||||
provider = RetainDBMemoryProvider()
|
||||
provider._client = object()
|
||||
|
||||
class _RunningThread:
|
||||
def join(self, timeout):
|
||||
pass
|
||||
|
||||
def is_alive(self):
|
||||
return True
|
||||
|
||||
previous = _RunningThread()
|
||||
provider._prefetch_threads = [previous]
|
||||
created = []
|
||||
|
||||
class _Thread:
|
||||
def __init__(self, *args, **kwargs):
|
||||
created.append((args, kwargs))
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(retaindb.threading, "Thread", _Thread)
|
||||
provider.queue_prefetch("query")
|
||||
assert provider._prefetch_threads == [previous]
|
||||
assert not created
|
||||
|
||||
|
||||
def test_upload_file_rejects_hermes_credential_store(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes_home"
|
||||
hermes_home.mkdir()
|
||||
auth_json = hermes_home / "auth.json"
|
||||
auth_json.write_text('{"OPENAI_API_KEY":"sk-test-secret"}', encoding="utf-8")
|
||||
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
|
||||
|
||||
provider = RetainDBMemoryProvider()
|
||||
provider._client = MagicMock()
|
||||
|
||||
result = provider._dispatch("retaindb_upload_file", {"local_path": str(auth_json)})
|
||||
|
||||
assert "error" in result
|
||||
assert "credential store" in result["error"]
|
||||
provider._client.upload_file.assert_not_called()
|
||||
|
||||
|
||||
def test_upload_file_allows_regular_file(tmp_path):
|
||||
note = tmp_path / "note.md"
|
||||
note.write_text("# Note\n", encoding="utf-8")
|
||||
provider = RetainDBMemoryProvider()
|
||||
provider._client = MagicMock()
|
||||
provider._client.upload_file.return_value = {
|
||||
"file": {"id": "file-1", "name": "note.md"},
|
||||
}
|
||||
|
||||
result = provider._dispatch("retaindb_upload_file", {"local_path": str(note)})
|
||||
|
||||
provider._client.upload_file.assert_called_once()
|
||||
assert provider._client.upload_file.call_args.args[0] == note.read_bytes()
|
||||
assert result["file"]["id"] == "file-1"
|
||||
|
||||
|
||||
def _capture_initialized_client(monkeypatch, tmp_path):
|
||||
"""Patch _Client/_WriteQueue/get_hermes_home; return a dict capturing args."""
|
||||
import hermes_constants
|
||||
|
||||
import plugins.memory.retaindb as retaindb_module
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, api_key, base_url, project):
|
||||
captured["api_key"] = api_key
|
||||
captured["base_url"] = base_url
|
||||
captured["project"] = project
|
||||
self.project = project
|
||||
|
||||
monkeypatch.setattr(retaindb_module, "_Client", _FakeClient)
|
||||
monkeypatch.setattr(retaindb_module, "_WriteQueue", lambda *a, **k: MagicMock())
|
||||
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
|
||||
return retaindb_module, captured
|
||||
|
||||
|
||||
def test_retaindb_config_loader_uses_readonly_config(monkeypatch):
|
||||
import hermes_cli.config as config_mod
|
||||
import plugins.memory.retaindb as retaindb_module
|
||||
|
||||
backing_config = {
|
||||
"memory": {
|
||||
"retaindb": {
|
||||
"base_url": "https://saved.example",
|
||||
"project": "saved-project",
|
||||
}
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(config_mod, "load_config_readonly", lambda: backing_config)
|
||||
monkeypatch.setattr(
|
||||
config_mod,
|
||||
"load_config",
|
||||
MagicMock(side_effect=AssertionError("read-only provider path must not load a mutable copy")),
|
||||
)
|
||||
|
||||
config = retaindb_module._load_retaindb_config()
|
||||
|
||||
assert config == backing_config["memory"]["retaindb"]
|
||||
assert config is not backing_config["memory"]["retaindb"]
|
||||
|
||||
|
||||
def test_initialize_reads_real_dashboard_config_file(tmp_path, monkeypatch):
|
||||
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"""\
|
||||
memory:
|
||||
provider: retaindb
|
||||
retaindb:
|
||||
base_url: https://retaindb.saved.example/
|
||||
project: dashboard-project
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
|
||||
|
||||
RetainDBMemoryProvider().initialize("sess-1")
|
||||
|
||||
assert captured["base_url"] == "https://retaindb.saved.example"
|
||||
assert captured["project"] == "dashboard-project"
|
||||
|
||||
|
||||
def test_initialize_reads_base_url_and_project_from_config_yaml(tmp_path, monkeypatch):
|
||||
"""#68209: non-secret base_url/project come from config.yaml when env is unset."""
|
||||
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
retaindb_module,
|
||||
"_load_retaindb_config",
|
||||
lambda: {"base_url": "https://retaindb.example.com/", "project": "cfg-project"},
|
||||
)
|
||||
|
||||
RetainDBMemoryProvider().initialize("sess-1")
|
||||
|
||||
assert captured["base_url"] == "https://retaindb.example.com" # trailing slash stripped
|
||||
assert captured["project"] == "cfg-project"
|
||||
|
||||
|
||||
def test_initialize_env_overrides_config_yaml(tmp_path, monkeypatch):
|
||||
for var in ("RETAINDB_API_KEY", "RETAINDB_PROJECT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
monkeypatch.setenv("RETAINDB_BASE_URL", "https://env.example.com")
|
||||
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
retaindb_module,
|
||||
"_load_retaindb_config",
|
||||
lambda: {"base_url": "https://cfg.example.com", "project": "cfg-project"},
|
||||
)
|
||||
|
||||
RetainDBMemoryProvider().initialize("sess-1")
|
||||
|
||||
assert captured["base_url"] == "https://env.example.com"
|
||||
|
||||
|
||||
def test_initialize_combines_scoped_secret_with_dashboard_config(tmp_path, monkeypatch):
|
||||
"""Rebase regression: scoped secrets and non-secret config must coexist."""
|
||||
from agent.secret_scope import (
|
||||
is_multiplex_active,
|
||||
reset_secret_scope,
|
||||
set_multiplex_active,
|
||||
set_secret_scope,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("RETAINDB_API_KEY", "env-other-profile")
|
||||
monkeypatch.delenv("RETAINDB_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("RETAINDB_PROJECT", raising=False)
|
||||
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
retaindb_module,
|
||||
"_load_retaindb_config",
|
||||
lambda: {"base_url": "https://dashboard.example.com/", "project": "dashboard-project"},
|
||||
)
|
||||
|
||||
previous_multiplex_state = is_multiplex_active()
|
||||
set_multiplex_active(True)
|
||||
token = set_secret_scope({"RETAINDB_API_KEY": "scoped-key"})
|
||||
try:
|
||||
RetainDBMemoryProvider().initialize("sess-1")
|
||||
finally:
|
||||
reset_secret_scope(token)
|
||||
set_multiplex_active(previous_multiplex_state)
|
||||
|
||||
assert captured == {
|
||||
"api_key": "scoped-key",
|
||||
"base_url": "https://dashboard.example.com",
|
||||
"project": "dashboard-project",
|
||||
}
|
||||
|
||||
|
||||
def test_initialize_falls_back_to_default_base_url(tmp_path, monkeypatch):
|
||||
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(retaindb_module, "_load_retaindb_config", lambda: {})
|
||||
|
||||
RetainDBMemoryProvider().initialize("sess-1")
|
||||
|
||||
assert captured["base_url"] == retaindb_module._DEFAULT_BASE_URL
|
||||
assert captured["project"] == "default"
|
||||
@@ -0,0 +1,458 @@
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.memory.supermemory import (
|
||||
SupermemoryMemoryProvider,
|
||||
_clean_text_for_capture,
|
||||
_format_connection_summary,
|
||||
_format_prefetch_context,
|
||||
_load_supermemory_config,
|
||||
_probe_supermemory_connection,
|
||||
_save_supermemory_config,
|
||||
)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid",
|
||||
base_url: str = ""):
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.container_tag = container_tag
|
||||
self.search_mode = search_mode
|
||||
self.base_url = base_url
|
||||
self.add_calls = []
|
||||
self.search_results = []
|
||||
self.profile_response = {"static": [], "dynamic": [], "search_results": []}
|
||||
self.ingest_calls = []
|
||||
self.forgotten_ids = []
|
||||
self.forget_by_query_response = {"success": True, "message": "Forgot"}
|
||||
|
||||
def add_memory(self, content, metadata=None, *, entity_context="",
|
||||
container_tag=None, custom_id=None):
|
||||
self.add_calls.append({
|
||||
"content": content,
|
||||
"metadata": metadata,
|
||||
"entity_context": entity_context,
|
||||
"container_tag": container_tag,
|
||||
"custom_id": custom_id,
|
||||
})
|
||||
return {"id": "mem_123"}
|
||||
|
||||
def search_memories(self, query, *, limit=5, container_tag=None, search_mode=None):
|
||||
return self.search_results
|
||||
|
||||
def get_profile(self, query=None, *, container_tag=None):
|
||||
return self.profile_response
|
||||
|
||||
def forget_memory(self, memory_id, *, container_tag=None):
|
||||
self.forgotten_ids.append(memory_id)
|
||||
|
||||
def forget_by_query(self, query, *, container_tag=None):
|
||||
return self.forget_by_query_response
|
||||
|
||||
def ingest_conversation(self, session_id, messages, metadata=None):
|
||||
self.ingest_calls.append({"session_id": session_id, "messages": messages, "metadata": metadata})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("session-1", hermes_home=str(tmp_path), platform="cli")
|
||||
return p
|
||||
|
||||
|
||||
def test_is_available_false_without_api_key(monkeypatch):
|
||||
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
|
||||
p = SupermemoryMemoryProvider()
|
||||
assert p.is_available() is False
|
||||
|
||||
|
||||
def test_load_and_save_config_round_trip(tmp_path):
|
||||
_save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path))
|
||||
cfg = _load_supermemory_config(str(tmp_path))
|
||||
# container_tag is kept raw — sanitization happens in initialize() after template resolution
|
||||
assert cfg["container_tag"] == "demo-tag"
|
||||
assert cfg["auto_capture"] is False
|
||||
assert cfg["auto_recall"] is True
|
||||
|
||||
|
||||
def test_clean_text_for_capture_strips_injected_context():
|
||||
text = "hello\n<supermemory-context>ignore me</supermemory-context>\nworld"
|
||||
assert _clean_text_for_capture(text) == "hello\nworld"
|
||||
|
||||
|
||||
def test_format_prefetch_context_deduplicates_overlap():
|
||||
result = _format_prefetch_context(
|
||||
static_facts=["Jordan prefers short answers"],
|
||||
dynamic_facts=["Jordan prefers short answers", "Uses Hermes"],
|
||||
search_results=[{"memory": "Uses Hermes", "similarity": 0.9}],
|
||||
max_results=10,
|
||||
)
|
||||
assert result.count("Jordan prefers short answers") == 1
|
||||
assert result.count("Uses Hermes") == 1
|
||||
assert "<supermemory-context>" in result
|
||||
|
||||
|
||||
def test_prefetch_includes_profile_on_first_turn(provider):
|
||||
provider._client.profile_response = {
|
||||
"static": ["Jordan prefers short answers"],
|
||||
"dynamic": ["Current project is Supermemory provider"],
|
||||
"search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
|
||||
}
|
||||
provider.on_turn_start(1, "start")
|
||||
result = provider.prefetch("what am I working on?")
|
||||
assert "User Profile (Persistent)" in result
|
||||
assert "Recent Context" in result
|
||||
assert "Relevant Memories" in result
|
||||
|
||||
|
||||
def test_sync_turn_buffers_short_messages(provider):
|
||||
# Trivial filtering is no longer applied at sync time — every non-empty turn
|
||||
# is buffered and only the full session is written at session boundaries.
|
||||
provider.sync_turn("ok", "sure", session_id="session-1")
|
||||
assert provider._session_turns == [{"user": "ok", "assistant": "sure"}]
|
||||
assert provider._client.add_calls == []
|
||||
|
||||
|
||||
def test_on_session_end_ingests_clean_messages(provider):
|
||||
messages = [
|
||||
{"role": "system", "content": "skip"},
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
provider.on_session_end(messages)
|
||||
assert len(provider._client.ingest_calls) == 1
|
||||
payload = provider._client.ingest_calls[0]
|
||||
assert payload["session_id"] == "session-1"
|
||||
assert payload["messages"] == [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
assert payload["metadata"]["type"] == "full_session"
|
||||
assert payload["metadata"]["session_id"] == "session-1"
|
||||
assert payload["metadata"]["message_count"] == 2
|
||||
# Buffer is cleared after a normal session-end ingest.
|
||||
assert provider._session_turns == []
|
||||
|
||||
|
||||
def test_merge_metadata_stamps_sm_source():
|
||||
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
|
||||
# app (functional routing, not telemetry) — must always be present.
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
client = _SupermemoryClient.__new__(_SupermemoryClient)
|
||||
merged = client._merge_metadata({"type": "explicit_memory"})
|
||||
assert merged["sm_source"] == "hermes"
|
||||
assert merged["type"] == "explicit_memory"
|
||||
|
||||
# Legacy "source" is migrated into "type" when type is absent.
|
||||
merged2 = client._merge_metadata({"source": "conversation_turn"})
|
||||
assert merged2["sm_source"] == "hermes"
|
||||
assert merged2["type"] == "conversation_turn"
|
||||
assert "source" not in merged2
|
||||
|
||||
|
||||
def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch):
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_add_memory(content, metadata=None, *, entity_context="",
|
||||
container_tag=None, custom_id=None):
|
||||
started.set()
|
||||
release.wait(timeout=1)
|
||||
provider._client.add_calls.append({
|
||||
"content": content,
|
||||
"metadata": metadata,
|
||||
"entity_context": entity_context,
|
||||
})
|
||||
return {"id": "mem_slow"}
|
||||
|
||||
monkeypatch.setattr(provider._client, "add_memory", slow_add_memory)
|
||||
|
||||
# sync_turn now only buffers — no thread is spawned.
|
||||
provider.sync_turn(
|
||||
"Please remember this request in long-term memory",
|
||||
"Absolutely, I will keep that in long-term memory.",
|
||||
session_id="session-1",
|
||||
)
|
||||
assert provider._sync_thread is None
|
||||
assert len(provider._session_turns) == 1
|
||||
|
||||
# on_memory_write still runs on a background thread.
|
||||
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
|
||||
assert started.wait(timeout=1)
|
||||
assert provider._write_thread is not None
|
||||
|
||||
release.set()
|
||||
provider.shutdown()
|
||||
|
||||
# All tracked threads joined and cleared.
|
||||
assert provider._sync_thread is None
|
||||
assert provider._write_thread is None
|
||||
assert provider._prefetch_thread is None
|
||||
# Explicit memory write went through.
|
||||
assert len(provider._client.add_calls) == 1
|
||||
# Buffered turn was flushed as a partial full-session ingest.
|
||||
assert len(provider._client.ingest_calls) == 1
|
||||
payload = provider._client.ingest_calls[0]
|
||||
assert payload["session_id"] == "session-1"
|
||||
assert payload["metadata"]["partial"] is True
|
||||
assert payload["metadata"]["type"] == "full_session"
|
||||
|
||||
|
||||
def test_store_tool_returns_saved_payload(provider):
|
||||
result = json.loads(provider.handle_tool_call("supermemory_store", {"content": "Jordan likes concise docs"}))
|
||||
assert result["saved"] is True
|
||||
assert result["id"] == "mem_123"
|
||||
|
||||
|
||||
def test_search_tool_formats_results(provider):
|
||||
provider._client.search_results = [
|
||||
{"id": "m1", "memory": "Jordan likes concise docs", "similarity": 0.92}
|
||||
]
|
||||
result = json.loads(provider.handle_tool_call("supermemory_search", {"query": "concise docs"}))
|
||||
assert result["count"] == 1
|
||||
assert result["results"][0]["similarity"] == 92
|
||||
|
||||
|
||||
def test_forget_tool_by_id(provider):
|
||||
result = json.loads(provider.handle_tool_call("supermemory_forget", {"id": "m1"}))
|
||||
assert result == {"forgotten": True, "id": "m1"}
|
||||
assert provider._client.forgotten_ids == ["m1"]
|
||||
|
||||
|
||||
def test_profile_tool_formats_sections(provider):
|
||||
provider._client.profile_response = {
|
||||
"static": ["Jordan prefers concise docs"],
|
||||
"dynamic": ["Working on Supermemory provider"],
|
||||
"search_results": [],
|
||||
}
|
||||
result = json.loads(provider.handle_tool_call("supermemory_profile", {}))
|
||||
assert result["static_count"] == 1
|
||||
assert result["dynamic_count"] == 1
|
||||
assert "User Profile (Persistent)" in result["profile"]
|
||||
|
||||
|
||||
def test_handle_tool_call_returns_error_when_unconfigured(monkeypatch):
|
||||
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
|
||||
p = SupermemoryMemoryProvider()
|
||||
result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# -- Identity template tests --------------------------------------------------
|
||||
|
||||
|
||||
def test_identity_template_resolved_in_container_tag(monkeypatch, tmp_path):
|
||||
"""container_tag with {identity} resolves to profile-scoped tag."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli", agent_identity="coder")
|
||||
assert p._container_tag == "hermes_coder"
|
||||
|
||||
|
||||
def test_container_tag_env_var_override(monkeypatch, tmp_path):
|
||||
"""SUPERMEMORY_CONTAINER_TAG env var overrides config."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setenv("SUPERMEMORY_CONTAINER_TAG", "env-override")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._container_tag == "env_override"
|
||||
|
||||
|
||||
# -- Search mode tests --------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path):
|
||||
"""Invalid search_mode falls back to 'hybrid'."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"search_mode": "invalid_mode"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._search_mode == "hybrid"
|
||||
|
||||
|
||||
# -- Base URL tests -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_base_url_defaults_to_cloud(monkeypatch, tmp_path):
|
||||
"""Without config or env override, the client targets api.supermemory.ai."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.delenv("SUPERMEMORY_BASE_URL", raising=False)
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._base_url == "https://api.supermemory.ai"
|
||||
assert p._client.base_url == "https://api.supermemory.ai"
|
||||
|
||||
|
||||
def test_client_passes_custom_base_url_to_sdk(monkeypatch):
|
||||
"""SDK operations and raw conversation ingest share one normalized base URL."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
captured = {}
|
||||
|
||||
class StubSupermemory:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
module = types.ModuleType("supermemory")
|
||||
module.Supermemory = StubSupermemory
|
||||
monkeypatch.setitem(sys.modules, "supermemory", module)
|
||||
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *args, **kwargs: None)
|
||||
|
||||
client = _SupermemoryClient(
|
||||
api_key="test-key",
|
||||
timeout=1.0,
|
||||
container_tag="hermes",
|
||||
base_url="http://localhost:6767/",
|
||||
)
|
||||
|
||||
assert client._base_url == "http://localhost:6767"
|
||||
assert captured["base_url"] == "http://localhost:6767"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("base_url", "expected_url"),
|
||||
[
|
||||
("https://api.supermemory.ai", "https://api.supermemory.ai/v4/conversations"),
|
||||
("http://localhost:6767", "http://localhost:6767/v4/conversations"),
|
||||
],
|
||||
)
|
||||
def test_ingest_conversation_uses_client_base_url(monkeypatch, base_url, expected_url):
|
||||
"""Raw conversation ingest follows the same endpoint as SDK operations."""
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
client = _SupermemoryClient.__new__(_SupermemoryClient)
|
||||
client._api_key = "test-key"
|
||||
client._container_tag = "hermes"
|
||||
client._timeout = 1.0
|
||||
client._base_url = base_url
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
return _FakeResponse()
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
|
||||
client.ingest_conversation("s1", [{"role": "user", "content": "hello there"}])
|
||||
assert captured["url"] == expected_url
|
||||
|
||||
|
||||
# -- Multi-container tests ----------------------------------------------------
|
||||
|
||||
|
||||
def test_multi_container_disabled_by_default(provider):
|
||||
"""Multi-container is off by default; schemas have no container_tag param."""
|
||||
assert provider._enable_custom_containers is False
|
||||
schemas = provider.get_tool_schemas()
|
||||
for s in schemas:
|
||||
assert "container_tag" not in s["parameters"]["properties"]
|
||||
|
||||
|
||||
def test_get_config_schema_minimal():
|
||||
"""get_config_schema only returns the API key field."""
|
||||
p = SupermemoryMemoryProvider()
|
||||
schema = p.get_config_schema()
|
||||
assert len(schema) == 1
|
||||
assert schema[0]["key"] == "api_key"
|
||||
assert schema[0]["secret"] is True
|
||||
|
||||
|
||||
def test_probe_supermemory_connection_missing_key(tmp_path):
|
||||
status = _probe_supermemory_connection("", str(tmp_path))
|
||||
assert status["ok"] is False
|
||||
assert status["error"] == "SUPERMEMORY_API_KEY not set"
|
||||
assert status["container_tag"] == "hermes"
|
||||
|
||||
|
||||
def _stub_supermemory_importable(monkeypatch):
|
||||
"""Make ``__import__("supermemory")`` succeed without the real package.
|
||||
|
||||
``_probe_supermemory_connection`` guards on ``__import__("supermemory")``
|
||||
before using the (mocked) client, so tests that mock ``_SupermemoryClient``
|
||||
must also satisfy that import guard — otherwise they only pass in an
|
||||
environment where the optional ``supermemory`` package happens to be
|
||||
installed (and fail on a clean checkout / CI). Mirrors the inverse stub in
|
||||
``test_is_available_false_when_import_missing``.
|
||||
"""
|
||||
import builtins
|
||||
import types
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "supermemory":
|
||||
return types.ModuleType("supermemory")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
|
||||
def test_post_setup_writes_config_and_prints_summary(monkeypatch, tmp_path, capsys):
|
||||
config: dict = {"memory": {}}
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.memory_setup._prompt",
|
||||
lambda label, secret=True, default=None: "new-api-key",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.supermemory._probe_supermemory_connection",
|
||||
lambda api_key, hermes_home, **kwargs: {
|
||||
"ok": True,
|
||||
"container_tag": "hermes",
|
||||
"profile_facts": 3,
|
||||
"auto_recall": True,
|
||||
"auto_capture": True,
|
||||
},
|
||||
)
|
||||
|
||||
saved: dict = {}
|
||||
|
||||
def fake_save_config(cfg):
|
||||
saved.update(cfg)
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", fake_save_config)
|
||||
|
||||
SupermemoryMemoryProvider().post_setup(str(tmp_path), config)
|
||||
|
||||
assert config["memory"]["provider"] == "supermemory"
|
||||
assert saved["memory"]["provider"] == "supermemory"
|
||||
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
|
||||
assert "SUPERMEMORY_API_KEY=new-api-key" in env_text
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "✓ Connected" in out
|
||||
assert "3 profile facts" in out
|
||||
assert "Memory provider: supermemory" in out
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
|
||||
def test_save_config_sets_owner_only_permissions(tmp_path):
|
||||
"""supermemory.json must be written with 0o600 so API key is not world-readable."""
|
||||
_save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path))
|
||||
config_file = tmp_path / "supermemory.json"
|
||||
assert config_file.exists()
|
||||
mode = stat.S_IMODE(config_file.stat().st_mode)
|
||||
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
|
||||
Reference in New Issue
Block a user