deer-flow/backend/tests/test_honcho_memory_backend.py
Hao Zhe ec274bdedb
fix(memory): enforce backend read failure policy (#4726)
* fix(memory): enforce backend read failure policy

* fix(memory): harden failure policy handling

* fix(memory): narrow strict read handling

* fix(memory): keep timeout handling off saturated executor

* fix(memory): preserve legacy fail-closed timeouts
2026-09-06 09:01:33 +08:00

550 lines
24 KiB
Python

"""Tests for the Honcho memory backend (config + manager)."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import httpx
import pytest
from deerflow.agents.memory.backends.honcho.client import HonchoClient, HonchoRequestError
from deerflow.agents.memory.backends.honcho.config import HonchoConfig, sanitize_id
from deerflow.agents.memory.backends.honcho.honcho_manager import HonchoMemoryManager, _stable_id
from deerflow.agents.memory.manager import MemoryManagerError, MemoryReadError
class TestHonchoConfig:
def test_defaults(self):
cfg = HonchoConfig.from_backend_config(None)
assert cfg.base_url == "http://localhost:8000"
assert cfg.api_key is None
assert cfg.workspace_prefix == "deerflow-u-"
assert cfg.workspace_overrides == {}
assert cfg.user_peer_overrides == {}
assert cfg.assistant_peer == "deerflow"
assert cfg.timeout_seconds == 10.0
assert cfg.connect_timeout_seconds == 3.0
assert cfg.message_char_limit == 8000
assert cfg.max_injection_chars == 6000
assert cfg.read_fail_closed is False
def test_parses_knobs_and_ignores_unknown_keys(self):
cfg = HonchoConfig.from_backend_config(
{
"base_url": "https://api.honcho.dev/",
"api_key": "sk-test",
"workspace_prefix": "df-",
"workspace_overrides": {"user-1": "shared"},
"user_peer_overrides": {"user-1": "alice"},
"assistant_peer": "deer",
"failure_policy": {"read": "fail_closed"},
"storage_path": "/tmp/x",
"unknown_key": True,
}
)
assert cfg.base_url == "https://api.honcho.dev" # trailing slash stripped
assert cfg.workspace_overrides == {"user-1": "shared"}
assert cfg.user_peer_overrides == {"user-1": "alice"}
assert cfg.read_fail_closed is True
assert cfg.storage_path == "/tmp/x"
def test_http_with_api_key_requires_opt_in(self):
with pytest.raises(ValueError, match="allow_insecure_http"):
HonchoConfig.from_backend_config({"base_url": "http://internal:8000", "api_key": "sk-x"})
cfg = HonchoConfig.from_backend_config({"base_url": "http://internal:8000", "api_key": "sk-x", "allow_insecure_http": True})
assert cfg.api_key == "sk-x"
def test_http_without_api_key_is_fine(self):
cfg = HonchoConfig.from_backend_config({"base_url": "http://host.docker.internal:8000"})
assert cfg.api_key is None
def test_empty_override_values_rejected(self):
"""An override entry with an empty/null value is a config mistake: silently
falling through to the default derivation (empty string is falsy) or
stringifying YAML null into a workspace literally named "None" would both
mask the operator's intent. Fail fast at parse time instead."""
with pytest.raises(ValueError, match="workspace_overrides"):
HonchoConfig.from_backend_config({"workspace_overrides": {"alice": ""}})
with pytest.raises(ValueError, match="workspace_overrides"):
HonchoConfig.from_backend_config({"workspace_overrides": {"alice": None}})
with pytest.raises(ValueError, match="user_peer_overrides"):
HonchoConfig.from_backend_config({"user_peer_overrides": {"bob": " "}})
@pytest.mark.parametrize(
("key", "value"),
[
pytest.param("timeout_seconds", 0, id="timeout-zero"),
pytest.param("timeout_seconds", -1, id="timeout-negative"),
pytest.param("timeout_seconds", float("nan"), id="timeout-nan"),
pytest.param("timeout_seconds", float("inf"), id="timeout-inf"),
pytest.param("timeout_seconds", float("-inf"), id="timeout-neg-inf"),
pytest.param("connect_timeout_seconds", 0, id="connect-timeout-zero"),
pytest.param("connect_timeout_seconds", -1, id="connect-timeout-negative"),
pytest.param("connect_timeout_seconds", float("nan"), id="connect-timeout-nan"),
pytest.param("connect_timeout_seconds", float("inf"), id="connect-timeout-inf"),
pytest.param("connect_timeout_seconds", float("-inf"), id="connect-timeout-neg-inf"),
],
)
def test_rejects_invalid_timeouts(self, key, value):
with pytest.raises(ValueError, match=key):
HonchoConfig.from_backend_config({key: value})
@pytest.mark.parametrize("key", ["message_char_limit", "max_injection_chars"])
@pytest.mark.parametrize("value", [0, -1])
def test_rejects_non_positive_character_limits(self, key, value):
with pytest.raises(ValueError, match=key):
HonchoConfig.from_backend_config({key: value})
@pytest.mark.parametrize(
("key", "value"),
[
pytest.param("timeout_seconds", float("inf"), id="timeout"),
pytest.param("connect_timeout_seconds", 0, id="connect-timeout"),
pytest.param("message_char_limit", -1, id="message-limit"),
pytest.param("max_injection_chars", 0, id="injection-limit"),
],
)
def test_direct_construction_enforces_limits(self, key, value):
with pytest.raises(ValueError, match=key):
HonchoConfig(**{key: value})
def test_accepts_custom_positive_timeouts_and_character_limits(self):
cfg = HonchoConfig.from_backend_config(
{
"timeout_seconds": 2.5,
"connect_timeout_seconds": 0.5,
"message_char_limit": 12,
"max_injection_chars": 8,
}
)
assert cfg.timeout_seconds == 2.5
assert cfg.connect_timeout_seconds == 0.5
assert cfg.message_char_limit == 12
assert cfg.max_injection_chars == 8
class TestSanitizeId:
def test_passthrough_and_cleanup(self):
assert sanitize_id("user_1-ok") == "user_1-ok"
assert sanitize_id("weird id@example.com") == "weird-id-example-com"
assert len(sanitize_id("x" * 200)) == 64
assert sanitize_id("") == ""
def _client_with_handler(handler, **cfg_over):
cfg = HonchoConfig.from_backend_config({"base_url": "http://honcho.test", **cfg_over})
return HonchoClient(cfg, transport=httpx.MockTransport(handler))
class TestHonchoClient:
def test_paths_and_payloads(self):
seen: list[tuple[str, str, bytes]] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.method, request.url.path, request.content))
if request.url.path.endswith("/representation"):
return httpx.Response(200, json={"representation": "knows things"})
if request.url.path.endswith("/search"):
return httpx.Response(200, json=[{"content": "hit", "peer_id": "p", "session_id": "s", "created_at": "t"}])
return httpx.Response(200, json={"id": "x"})
c = _client_with_handler(handler)
c.get_or_create_peer("ws1", "alice")
c.get_or_create_session("ws1", "df-t1")
c.set_session_peers("ws1", "df-t1", ["alice", "deerflow"])
c.add_messages("ws1", "df-t1", [{"peer_id": "alice", "content": "hi"}])
assert c.working_representation("ws1", "alice", max_conclusions=10) == "knows things"
assert c.search("ws1", "q", limit=5)[0]["content"] == "hit"
paths = [p for _, p, _ in seen]
assert paths == [
"/v3/workspaces/ws1/peers",
"/v3/workspaces/ws1/sessions",
"/v3/workspaces/ws1/sessions/df-t1/peers",
"/v3/workspaces/ws1/sessions/df-t1/messages",
"/v3/workspaces/ws1/peers/alice/representation",
"/v3/workspaces/ws1/search",
]
assert b'"alice"' in seen[2][2] and b'"deerflow"' in seen[2][2]
assert b'"messages"' in seen[3][2]
def test_errors_wrap_as_honcho_request_error(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
c = _client_with_handler(handler)
with pytest.raises(HonchoRequestError):
c.get_or_create_peer("ws1", "alice")
def test_non_json_200_response_wraps_as_honcho_request_error(self):
"""A 200 with a non-JSON body (e.g. a maintenance page from a proxy in
front of Honcho) must not let a bare JSONDecodeError escape -- it has
to surface through the same HonchoRequestError contract as any other
transport failure so callers have exactly one exception type to
handle."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="<html>maintenance</html>")
c = _client_with_handler(handler)
with pytest.raises(HonchoRequestError):
c.get_or_create_peer("ws1", "alice")
def test_api_key_header(self):
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers.get("Authorization") == "Bearer sk-h"
return httpx.Response(200, json={"id": "x"})
c = _client_with_handler(handler, api_key="sk-h", allow_insecure_http=True)
c.get_or_create_peer("ws1", "alice")
class _FakeClient:
"""Records calls; canned returns. Replaces the real HonchoClient in tests."""
def __init__(self):
self.calls: list[tuple[str, tuple]] = []
self.representation_text = "ajayr likes concise answers"
self.raise_on: str | None = None
# Which exception type _maybe_raise raises; default matches the real
# HonchoClient's contract. Tests override this to a non-HonchoRequestError
# (e.g. RuntimeError) to prove the manager's boundary excepts are broad,
# not narrowly typed to the client's own exception class.
self.raise_exc_cls: type[BaseException] = HonchoRequestError
self.closed = False
def _maybe_raise(self, name):
if self.raise_on == name:
raise self.raise_exc_cls(name)
def get_or_create_peer(self, ws, pid):
self._maybe_raise("peer")
self.calls.append(("peer", (ws, pid)))
def get_or_create_session(self, ws, sid):
self._maybe_raise("session")
self.calls.append(("session", (ws, sid)))
def set_session_peers(self, ws, sid, pids):
self._maybe_raise("peers")
self.calls.append(("set_peers", (ws, sid, tuple(pids))))
def add_messages(self, ws, sid, msgs):
self._maybe_raise("messages")
self.calls.append(("messages", (ws, sid, tuple((m["peer_id"], m["content"]) for m in msgs))))
def working_representation(self, ws, pid, *, max_conclusions=25):
self._maybe_raise("representation")
self.calls.append(("rep", (ws, pid)))
return self.representation_text
def search(self, ws, query, *, limit=5):
self._maybe_raise("search")
self.calls.append(("search", (ws, query, limit)))
return [{"content": "found", "peer_id": "deerflow", "session_id": "df-t", "created_at": "2026-01-01"}]
def close(self):
self.closed = True
def _manager(**backend_config):
mgr = HonchoMemoryManager.from_config({"base_url": "http://honcho.test", **backend_config})
fake = _FakeClient()
mgr._client = fake
return mgr, fake
def _msg(msg_type, content):
return SimpleNamespace(type=msg_type, content=content)
class TestHonchoClientTransport:
def test_constructor_accepts_transport_for_tests(self):
"""Tests inject httpx.MockTransport through the constructor (Mem0Client
precedent) instead of rebuilding and overwriting client._http."""
seen: list[str] = []
def handler(request):
seen.append(request.url.path)
return httpx.Response(200)
cfg = HonchoConfig.from_backend_config({"base_url": "http://honcho.test"})
client = HonchoClient(cfg, transport=httpx.MockTransport(handler))
client.get_or_create_peer("w1", "p1")
assert seen == ["/v3/workspaces/w1/peers"]
class TestHonchoManagerWrite:
def test_add_maps_messages_to_peers(self):
mgr, fake = _manager(workspace_overrides={"u1": "shared"}, user_peer_overrides={"u1": "alice"})
mgr.add("t-1", [_msg("human", "please check the deploy"), _msg("ai", "deploy is green"), _msg("tool", "ignored")], user_id="u1")
# Session ids share the user-id path's collision-resistant derivation;
# computed, not hardcoded (see test_add_prefix_workspace_for_unmapped_user).
sid = f"df-{_stable_id('t-1')}"
assert ("messages", ("shared", sid, (("alice", "please check the deploy"), ("deerflow", "deploy is green")))) in fake.calls
assert ("set_peers", ("shared", sid, ("alice", "deerflow"))) in fake.calls
def test_add_without_user_is_noop(self):
mgr, fake = _manager()
mgr.add("t-1", [_msg("human", "hello")], user_id=None)
assert fake.calls == []
def test_add_with_empty_string_user_is_noop(self):
mgr, fake = _manager()
mgr.add("t-1", [_msg("human", "hello")], user_id="")
assert fake.calls == []
def test_add_prefix_workspace_for_unmapped_user(self):
mgr, fake = _manager()
mgr.add("t-2", [_msg("human", "x")], user_id="bob@example.com")
# Computed via the same _stable_id formula the manager uses, not
# hardcoded, so this test doesn't silently drift from the real
# collision-resistant derivation (see TestHonchoIdentityDerivation).
expected_peer = _stable_id("bob@example.com")
assert fake.calls[0] == ("peer", (f"deerflow-u-{expected_peer}", expected_peer))
def test_add_swallows_backend_errors(self):
mgr, fake = _manager()
fake.raise_on = "messages"
mgr.add("t-3", [_msg("human", "x")], user_id="u1") # must not raise
def test_add_swallows_non_honcho_exceptions(self):
"""The boundary except is broad (except Exception), not narrowly typed
to HonchoRequestError -- any client failure (e.g. a bug surfacing as a
bare RuntimeError, or the non-JSON-response case in TestHonchoClient)
must not escape add() and crash MemoryMiddleware.after_agent."""
mgr, fake = _manager()
fake.raise_on = "messages"
fake.raise_exc_cls = RuntimeError
mgr.add("t-6", [_msg("human", "x")], user_id="u1") # must not raise
def test_add_truncates_long_content(self):
mgr, fake = _manager(message_char_limit=10)
mgr.add("t-4", [_msg("human", "0123456789ABCDEF")], user_id="u1")
sent = [c for c in fake.calls if c[0] == "messages"][0][1][2][0][1]
assert len(sent) == 10
def test_from_config_rejects_negative_message_char_limit(self):
"""add() uses ``text[:message_char_limit]``. A negative limit is a
Python negative slice (``text[:-1]``), which deletes a suffix instead
of capping length. Fail at config parse so the manager never sees it.
"""
with pytest.raises(ValueError, match="message_char_limit"):
HonchoMemoryManager.from_config({"base_url": "http://honcho.test", "message_char_limit": -1})
def test_from_config_rejects_zero_max_injection_chars(self):
"""get_context() uses ``representation[:max_injection_chars]``. Zero
would inject an empty memory string; reject it at startup.
"""
with pytest.raises(ValueError, match="max_injection_chars"):
HonchoMemoryManager.from_config({"base_url": "http://honcho.test", "max_injection_chars": 0})
def test_add_normalizes_list_content(self):
mgr, fake = _manager()
mgr.add("t-5", [_msg("human", [{"type": "text", "text": "part1"}, {"type": "text", "text": "part2"}])], user_id="u1")
sent = [c for c in fake.calls if c[0] == "messages"][0][1][2][0][1]
assert "part1" in sent and "part2" in sent
def test_session_ids_resist_sanitize_collisions(self):
"""Same lossy-sanitization hazard as user ids, one tier down: thread ids
"t.1" and "t-1" both sanitize to "t-1", so bare sanitize_id would merge
two threads' histories into one Honcho session. Session ids must use the
same collision-resistant _stable_id derivation as the user-id path."""
mgr, fake = _manager()
mgr.add("t.1", [_msg("human", "x")], user_id="u1")
mgr.add("t-1", [_msg("human", "y")], user_id="u1")
session_ids = {c[1][1] for c in fake.calls if c[0] == "session"}
assert len(session_ids) == 2
class TestHonchoManagerRead:
def test_get_context_returns_representation(self):
mgr, fake = _manager(workspace_overrides={"u1": "shared"})
assert "concise answers" in mgr.get_context("u1")
def test_get_context_without_user_is_empty(self):
mgr, _ = _manager()
assert mgr.get_context(None) == ""
def test_get_context_with_empty_string_user_is_empty(self):
mgr, fake = _manager()
assert mgr.get_context("") == ""
assert fake.calls == []
def test_get_context_truncates(self):
mgr, fake = _manager(max_injection_chars=10)
fake.representation_text = "y" * 100
assert len(mgr.get_context("u1")) <= 10
def test_get_context_fail_open_by_default(self):
mgr, fake = _manager()
fake.raise_on = "representation"
assert mgr.get_context("u1") == ""
def test_get_context_fail_closed_raises_contract_error(self):
mgr, fake = _manager(failure_policy={"read": "fail_closed"})
fake.raise_on = "representation"
assert mgr.read_failures_are_fatal is True
with pytest.raises(MemoryReadError):
mgr.get_context("u1")
def test_get_context_fail_open_swallows_non_honcho_exceptions(self):
mgr, fake = _manager()
fake.raise_on = "representation"
fake.raise_exc_cls = RuntimeError
assert mgr.get_context("u1") == ""
def test_get_context_fail_closed_wraps_non_honcho_exceptions(self):
mgr, fake = _manager(failure_policy={"read": "fail_closed"})
fake.raise_on = "representation"
fake.raise_exc_cls = RuntimeError
with pytest.raises(MemoryManagerError):
mgr.get_context("u1")
def test_search_maps_results(self):
mgr, _ = _manager()
results = mgr.search("deploy", top_k=3, user_id="u1")
assert results[0]["content"] == "found"
def test_search_without_user_is_empty(self):
mgr, _ = _manager()
assert mgr.search("q", user_id=None) == []
def test_supports_search_flag_matches_override(self):
mgr, _ = _manager()
assert type(mgr).supports_search is True
def test_search_fail_open_by_default(self):
mgr, fake = _manager()
fake.raise_on = "search"
assert mgr.search("q", user_id="u1") == []
def test_search_fail_closed_raises_contract_error(self):
"""search() is a recall op (the tool-mode memory_search path): it must
honor failure_policy.read like get_context() does, not silently return
[] and mask an outage from the model and the operator."""
mgr, fake = _manager(failure_policy={"read": "fail_closed"})
fake.raise_on = "search"
with pytest.raises(MemoryManagerError):
mgr.search("q", user_id="u1")
def test_search_fail_closed_wraps_non_honcho_exceptions(self):
mgr, fake = _manager(failure_policy={"read": "fail_closed"})
fake.raise_on = "search"
fake.raise_exc_cls = RuntimeError
with pytest.raises(MemoryManagerError):
mgr.search("q", user_id="u1")
def test_get_memory_minimal_shape(self):
mgr, _ = _manager()
doc = mgr.get_memory(user_id="u1")
assert doc["facts"] == []
assert doc["user"]["workContext"]["summary"]
assert doc["lastUpdated"]
def test_get_memory_without_user_is_empty_with_no_calls(self):
mgr, fake = _manager()
doc = mgr.get_memory(user_id=None)
assert doc["facts"] == []
assert doc["user"] == {}
assert doc["history"] == {}
assert doc["lastUpdated"]
assert fake.calls == []
def test_get_memory_fail_open_by_default(self):
mgr, fake = _manager()
fake.raise_on = "representation"
doc = mgr.get_memory(user_id="u1")
assert doc["facts"] == []
assert doc["user"] == {}
def test_get_memory_fail_closed_raises_contract_error(self):
"""get_memory() backs the /memory gateway endpoint — a recall op, so it
follows failure_policy.read like get_context() and search()."""
mgr, fake = _manager(failure_policy={"read": "fail_closed"})
fake.raise_on = "representation"
with pytest.raises(MemoryManagerError):
mgr.get_memory(user_id="u1")
class TestHonchoManagerAsync:
def test_async_entrypoints_delegate(self):
mgr, fake = _manager()
async def run():
await mgr.aadd("t-1", [_msg("human", "x")], user_id="u1")
ctx = await mgr.aget_context("u1")
hits = await mgr.asearch("q", user_id="u1")
return ctx, hits
ctx, hits = asyncio.run(run())
assert "concise" in ctx and hits
class TestHonchoManagerLifecycle:
def test_shutdown_flush_true(self):
mgr, _ = _manager()
assert mgr.shutdown_flush(1.0) is True
def test_tool_mode_accepted(self):
mgr = HonchoMemoryManager.from_config({"base_url": "http://honcho.test"}, mode="tool")
assert mgr.mode == "tool"
def test_requires_passive_writes_in_tool_mode(self):
"""Honcho's only write path is passive add(); fact CRUD is intentionally
unsupported, so tool mode must keep MemoryMiddleware writes flowing to
Honcho's deriver alongside model-directed search(). Mirrors mem0's
identical ClassVar (mem0_manager.py)."""
assert HonchoMemoryManager.requires_passive_writes_in_tool_mode is True
def test_close_releases_http_client(self):
"""close() is the gateway shutdown hook (manager.py base default is a
no-op); Honcho must override it to release the underlying HTTP client,
mirroring mem0_manager.py's identical close()."""
mgr, fake = _manager()
mgr.close()
assert fake.closed is True
class TestHonchoIdentityDerivation:
"""Pins the collision-resistant default (non-override) identity derivation.
``sanitize_id`` alone is lossy: distinct raw ids can sanitize to the same
string, which would merge two different users' memory into one Honcho
workspace/peer if used bare. ``_stable_id`` (workspace/peer default path)
must keep such inputs apart.
"""
def test_default_workspace_and_peer_resist_sanitize_collisions(self):
mgr, _ = _manager()
raw_a = "user.name@example.com"
raw_b = "user-name@example.com"
# Precondition: these two distinct raw ids really do collide under
# plain sanitize_id -- otherwise this test would not exercise the bug
# the collision-resistant derivation fixes.
assert sanitize_id(raw_a) == sanitize_id(raw_b)
ws_a, ws_b = mgr._workspace(raw_a), mgr._workspace(raw_b)
peer_a, peer_b = mgr._user_peer(raw_a), mgr._user_peer(raw_b)
assert ws_a != ws_b
assert peer_a != peer_b
def test_user_peer_never_empty_for_degenerate_raw_id(self):
mgr, _ = _manager()
# "!!!" sanitizes to "" (every character stripped); the default
# derivation must still produce a non-empty, usable peer/workspace id.
assert sanitize_id("!!!") == ""
peer = mgr._user_peer("!!!")
assert peer != ""
ws = mgr._workspace("!!!")
assert ws is not None
assert ws != mgr._config.workspace_prefix
class TestFactoryDiscovery:
def test_manager_class_resolves(self):
from deerflow.agents.memory.backends.honcho import MANAGER_CLASS
assert MANAGER_CLASS is HonchoMemoryManager