mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(memory): filter honcho search by category instead of stamping it on unfiltered results HonchoMemoryManager.search sent an identical unfiltered query whether or not a category was requested, then stamped the requested category on every hit, so memory_search(category=...) returned unfiltered results attested to be that category (e.g. a work fact labeled "preference") — the opposite of the contract, where category filters before the top_k slice and only facts with that exact category are returned. Send the category as a real server-side metadata filter (Honcho applies "metadata" filters in its ranked query, before the limit) and label each result from the record's own metadata, falling back to "memory" as before. HonchoClient.search gains a keyword-only filters argument that is only put on the wire when set, so unscoped requests are byte-identical to before. * docs(memory): note honcho category filtering requires stored metadata
772 lines
35 KiB
Python
772 lines
35 KiB
Python
"""Tests for the Honcho memory backend (config + manager)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
import yaml
|
|
|
|
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
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
|
|
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_allow_insecure_http_false_keeps_the_guard(self):
|
|
"""An explicit ``False`` must not read as an opt-in."""
|
|
with pytest.raises(ValueError, match="allow_insecure_http"):
|
|
HonchoConfig.from_backend_config({"base_url": "http://internal:8000", "api_key": "sk-x", "allow_insecure_http": False})
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
("false", False),
|
|
("f", False),
|
|
("F", False),
|
|
("n", False),
|
|
("no", False),
|
|
("off", False),
|
|
("0", False),
|
|
("FALSE", False),
|
|
("true", True),
|
|
("t", True),
|
|
("T", True),
|
|
("y", True),
|
|
("Y", True),
|
|
("yes", True),
|
|
("on", True),
|
|
("1", True),
|
|
],
|
|
)
|
|
def test_allow_insecure_http_from_environment(self, monkeypatch, tmp_path, value, expected):
|
|
"""``$VAR`` substitution yields the raw environment string, so the knob
|
|
must be validated as a boolean: string truthiness turns ``"false"`` into
|
|
``True`` and silently sends the API key over plaintext HTTP. The
|
|
accepted literals are Pydantic's boolean vocabulary, so the documented
|
|
list is pinned here rather than in prose only."""
|
|
monkeypatch.setenv("TEST_HONCHO_ALLOW_INSECURE", value)
|
|
config_path = tmp_path / "config.yaml"
|
|
config_path.write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"models": [],
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"memory": {
|
|
"backend_config": {
|
|
"base_url": "http://internal:8000",
|
|
"api_key": "sk-x",
|
|
"allow_insecure_http": "$TEST_HONCHO_ALLOW_INSECURE",
|
|
}
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
config = AppConfig.from_file(config_path)
|
|
backend_config = config.memory.backend_config
|
|
assert backend_config["allow_insecure_http"] == value
|
|
|
|
if expected:
|
|
assert HonchoConfig.from_backend_config(backend_config).allow_insecure_http is True
|
|
else:
|
|
# The opt-in stays off, so the plaintext-http key guard still fires.
|
|
with pytest.raises(ValueError, match="allow_insecure_http"):
|
|
HonchoConfig.from_backend_config(backend_config)
|
|
|
|
@pytest.mark.parametrize("value", ["not-a-boolean", "2", "", [], "tru"])
|
|
def test_allow_insecure_http_rejects_a_non_boolean(self, value):
|
|
with pytest.raises(ValueError, match="allow_insecure_http must be a boolean"):
|
|
HonchoConfig.from_backend_config({"base_url": "http://internal:8000", "allow_insecure_http": value})
|
|
|
|
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
|
|
|
|
@pytest.mark.parametrize("value", ["localhost:8000", "honcho.internal", "not a url", "", "ftp://localhost:8000", "http://", "http://:8000", "https://:8000", 12345])
|
|
def test_malformed_base_url_rejected_as_config_error(self, value):
|
|
with pytest.raises(ValueError, match="base_url must be an absolute"):
|
|
HonchoConfig.from_backend_config({"base_url": value})
|
|
|
|
@pytest.mark.parametrize("value", ["localhost:8000", "http://", "http://:8000", "ftp://localhost:8000"])
|
|
def test_direct_construction_hits_the_same_guard(self, value):
|
|
"""The guard sits in ``__post_init__`` exactly so this path is covered: a
|
|
caller that builds ``HonchoConfig`` without ``from_backend_config`` must
|
|
not end up with a base_url httpx can never resolve."""
|
|
with pytest.raises(ValueError, match="base_url must be an absolute"):
|
|
HonchoConfig(base_url=value)
|
|
|
|
@pytest.mark.parametrize("value", ["HTTP://internal:8000", "HtTp://internal:8000", "http://internal:8000"])
|
|
def test_http_scheme_case_does_not_bypass_the_insecure_key_guard(self, value):
|
|
"""``urlsplit`` lowercases the scheme and httpx sends such a URL over plain
|
|
HTTP, so the api_key guard has to read the parsed scheme rather than a
|
|
case-sensitive ``http://`` prefix."""
|
|
with pytest.raises(ValueError, match="allow_insecure_http"):
|
|
HonchoConfig.from_backend_config({"base_url": value, "api_key": "sk-x"})
|
|
cfg = HonchoConfig.from_backend_config(
|
|
{"base_url": value, "api_key": "sk-x", "allow_insecure_http": True},
|
|
)
|
|
assert cfg.api_key == "sk-x"
|
|
|
|
@pytest.mark.parametrize("value", ["http://localhost:8000", "https://api.honcho.dev", "http://host.docker.internal:8000", "https://honcho.internal:8443"])
|
|
def test_absolute_base_urls_still_accepted(self, value):
|
|
assert HonchoConfig.from_backend_config({"base_url": f"{value}/"}).base_url == value
|
|
|
|
def test_manager_from_config_fails_fast_on_malformed_base_url(self):
|
|
with pytest.raises(ValueError, match="base_url must be an absolute"):
|
|
HonchoMemoryManager.from_config({"base_url": "localhost:8000"})
|
|
|
|
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("failure_policy", "fail_closed", id="policy-string"),
|
|
pytest.param("failure_policy", ["fail_closed"], id="policy-list"),
|
|
pytest.param("workspace_overrides", "shared", id="overrides-string"),
|
|
pytest.param("workspace_overrides", ["alice"], id="overrides-list"),
|
|
pytest.param("user_peer_overrides", 5, id="peer-overrides-int"),
|
|
],
|
|
)
|
|
def test_non_mapping_nested_values_rejected_as_config_error(self, key, value):
|
|
"""A truthy non-mapping is the operator's mistake, not an internal error:
|
|
mem0 and OpenViking raise ValueError for these same keys, so Honcho must
|
|
name the offending key instead of surfacing ``AttributeError`` from a
|
|
``.get``/``.items`` call inside backend construction."""
|
|
with pytest.raises(ValueError, match=f"{key} must be a mapping"):
|
|
HonchoConfig.from_backend_config({key: value})
|
|
|
|
@pytest.mark.parametrize("key", ["failure_policy", "workspace_overrides", "user_peer_overrides"])
|
|
@pytest.mark.parametrize("value", [None, "", [], {}])
|
|
def test_empty_nested_values_still_mean_unset(self, key, value):
|
|
cfg = HonchoConfig.from_backend_config({key: value})
|
|
assert cfg.read_fail_closed is False
|
|
assert cfg.workspace_overrides == {}
|
|
assert cfg.user_peer_overrides == {}
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "value"),
|
|
[
|
|
pytest.param("timeout_seconds", ["slow"], id="timeout-list"),
|
|
pytest.param("timeout_seconds", "slow", id="timeout-text"),
|
|
pytest.param("connect_timeout_seconds", {"seconds": 5}, id="connect-timeout-mapping"),
|
|
pytest.param("message_char_limit", "wide", id="message-limit-text"),
|
|
pytest.param("max_injection_chars", ["1000"], id="injection-limit-list"),
|
|
],
|
|
)
|
|
def test_unusable_numeric_values_rejected_as_config_error(self, key, value):
|
|
"""``float([...])`` and ``int("wide")`` raise a TypeError that names neither
|
|
the knob nor the config file, so a mistyped scalar reaches the operator as
|
|
an internal traceback — the same symptom as the nested values above, one
|
|
block below them."""
|
|
with pytest.raises(ValueError, match=f"{key} must be a number"):
|
|
HonchoConfig.from_backend_config({key: value})
|
|
|
|
@pytest.mark.parametrize(
|
|
("key", "default"),
|
|
[
|
|
("timeout_seconds", 10.0),
|
|
("connect_timeout_seconds", 3.0),
|
|
("message_char_limit", 8000),
|
|
("max_injection_chars", 6000),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("value", [None, "", " "])
|
|
def test_empty_numeric_values_still_mean_unset(self, key, default, value):
|
|
cfg = HonchoConfig.from_backend_config({key: value})
|
|
assert getattr(cfg, key) == default
|
|
|
|
@pytest.mark.parametrize(("key", "value", "expected"), [("timeout_seconds", "2.5", 2.5), ("message_char_limit", "120", 120)])
|
|
def test_numeric_strings_still_accepted(self, key, value, expected):
|
|
"""float()/int() already accept numeric strings, so a quoted YAML scalar
|
|
must keep working; the guard is only for values that cannot be cast."""
|
|
cfg = HonchoConfig.from_backend_config({key: value})
|
|
assert getattr(cfg, key) == expected
|
|
|
|
@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_search_payload_carries_filters_only_when_present(self):
|
|
"""``filters`` must reach the wire exactly as given (Honcho applies
|
|
``metadata`` filters server-side, before its ``limit``), while the
|
|
unfiltered payload stays byte-identical to the pre-filter shape so
|
|
deployed Honcho versions see no change on unscoped searches."""
|
|
|
|
seen: list[dict] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.url.path.endswith("/search"):
|
|
seen.append(json.loads(request.content))
|
|
return httpx.Response(200, json=[])
|
|
return httpx.Response(200, json={"id": "x"})
|
|
|
|
c = _client_with_handler(handler)
|
|
c.search("ws1", "q", limit=5)
|
|
assert seen == [{"query": "q", "limit": 5}]
|
|
c.search("ws1", "q", limit=5, filters={"metadata": {"category": "preference"}})
|
|
assert seen[-1] == {"query": "q", "limit": 5, "filters": {"metadata": {"category": "preference"}}}
|
|
|
|
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
|
|
self.search_results = [{"content": "found", "peer_id": "deerflow", "session_id": "df-t", "created_at": "2026-01-01"}]
|
|
|
|
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, filters=None):
|
|
self._maybe_raise("search")
|
|
self.calls.append(("search", (ws, query, limit, filters)))
|
|
return self.search_results[:limit]
|
|
|
|
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_category_scopes_the_wire_request(self):
|
|
"""Regression: ``category`` used to be dropped from the request entirely
|
|
-- the manager.py contract wants it to filter BEFORE the ``top_k`` slice
|
|
and memory_search (tools.py) promises "Only facts with this exact
|
|
category are returned". It must reach Honcho as a ``metadata`` filter,
|
|
which the server applies to its ranked query before its ``limit``."""
|
|
mgr, fake = _manager(workspace_overrides={"u1": "shared"})
|
|
mgr.search("tell me about the user", top_k=5, user_id="u1", category="preference")
|
|
assert fake.calls[-1] == ("search", ("shared", "tell me about the user", 5, {"metadata": {"category": "preference"}}))
|
|
|
|
def test_search_labels_results_from_their_own_metadata(self):
|
|
"""Regression: every unfiltered hit used to come back stamped with the
|
|
*requested* category; labels must come from each record's own metadata
|
|
(default ``memory``), never from the request."""
|
|
mgr, fake = _manager(workspace_overrides={"u1": "shared"})
|
|
fake.search_results = [
|
|
{"content": "User works at Acme on backend systems", "peer_id": "alice", "session_id": "df-t1", "created_at": "2026-01-01"},
|
|
{"content": "User likes hiking in the mountains", "metadata": {"category": "preference"}, "peer_id": "alice", "session_id": "df-t2", "created_at": "2026-01-02"},
|
|
]
|
|
results = mgr.search("tell me about the user", top_k=5, user_id="u1", category="preference")
|
|
assert [(r["category"], r["content"]) for r in results] == [
|
|
("memory", "User works at Acme on backend systems"),
|
|
("preference", "User likes hiking in the mountains"),
|
|
]
|
|
|
|
def test_search_without_category_keeps_the_wire_request_and_labels_unchanged(self):
|
|
"""Guard for the unfiltered path the fix must not disturb: no ``filters``
|
|
key is sent and the neutral ``memory`` label stays."""
|
|
mgr, fake = _manager(workspace_overrides={"u1": "shared"})
|
|
results = mgr.search("q", top_k=3, user_id="u1")
|
|
assert fake.calls[-1] == ("search", ("shared", "q", 3, None))
|
|
assert results == [{"content": "found", "category": "memory", "session_id": "df-t", "peer_id": "deerflow", "created_at": "2026-01-01"}]
|
|
|
|
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
|