diff --git a/backend/packages/harness/deerflow/agents/memory/AGENTS.md b/backend/packages/harness/deerflow/agents/memory/AGENTS.md index c583a30e78..a1bd18fdfc 100644 --- a/backend/packages/harness/deerflow/agents/memory/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/memory/AGENTS.md @@ -19,6 +19,9 @@ to the constructed backend and require backend recreation to change. This directory owns memory capture, storage, retrieval, prompt injection, and model-driven memory tools. +Honcho search rejects non-object, non-null `metadata` at the HTTP boundary: +apply the read policy to the whole result, with value-free diagnostics. + #### Main components - `manager.py` defines the backend-neutral `MemoryManager` contract. diff --git a/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md b/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md index 031bc7437a..20403f46a0 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md +++ b/backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md @@ -71,6 +71,13 @@ cannot see each other's memory by construction. workspace-scoped search, while the passive per-turn write middleware is retained because Honcho's deriver learns from `add()` writes. +Search result `metadata` must be an object, null, or absent. The HTTP client +rejects other containers before returning results, so `failure_policy.read` +also covers metadata-shape failures: fail-open returns no search results and +fail-closed raises `MemoryReadError`. A malformed later result does not expose +a partial result set. Diagnostics name the result index and field, without +echoing metadata or memory content. + ## Incremental conversation capture Checkpoint history is cumulative. For messages with stable source IDs, the diff --git a/backend/packages/harness/deerflow/agents/memory/backends/honcho/client.py b/backend/packages/harness/deerflow/agents/memory/backends/honcho/client.py index 8524a08a62..e2dcb721ae 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/honcho/client.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/honcho/client.py @@ -106,4 +106,9 @@ class HonchoClient: if filters: payload["filters"] = filters data = self._post(f"/v3/workspaces/{workspace}/search", payload) - return list(data) if isinstance(data, list) else [] + results = list(data) if isinstance(data, list) else [] + for index, item in enumerate(results): + metadata = item.get("metadata") if isinstance(item, dict) else None + if metadata is not None and not isinstance(metadata, dict): + raise HonchoRequestError(f"Honcho search results[{index}].metadata must be an object or null") + return results diff --git a/backend/tests/test_honcho_search_metadata_policy.py b/backend/tests/test_honcho_search_metadata_policy.py new file mode 100644 index 0000000000..2cff6e4201 --- /dev/null +++ b/backend/tests/test_honcho_search_metadata_policy.py @@ -0,0 +1,62 @@ +"""Malformed remote search metadata must honor the configured read policy.""" + +import json +import logging + +import httpx +import pytest + +from deerflow.agents.memory.backends.honcho.client import HonchoClient +from deerflow.agents.memory.backends.honcho.honcho_manager import HonchoMemoryManager +from deerflow.agents.memory.manager import MemoryReadError + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_read", [False, True]) +@pytest.mark.parametrize("policy", ["fail_open", "fail_closed"]) +@pytest.mark.parametrize("metadata", ["private-invalid-metadata", ["private-invalid-metadata"], 7, True]) +async def test_search_metadata_failure_honors_read_policy(metadata, policy, async_read, caplog): + manager = HonchoMemoryManager.from_config({"base_url": "http://honcho.test", "failure_policy": {"read": policy}}) + manager._client.close() + requests = [] + + def respond(request): + requests.append(request) + assert json.loads(request.content) == {"query": "synthetic query", "limit": 5} + return httpx.Response(200, json=[{"content": "private-valid-content", "metadata": {"category": "preference"}}, {"content": "private-invalid-content", "metadata": metadata}]) + + manager._client = HonchoClient(manager._config, transport=httpx.MockTransport(respond)) + + async def search(): + if async_read: + return await manager.asearch("synthetic query", user_id="synthetic-user") + return manager.search("synthetic query", user_id="synthetic-user") + + try: + with caplog.at_level(logging.WARNING): + if policy == "fail_closed": + with pytest.raises(MemoryReadError) as error: + await search() + diagnostics = str(error.value) + else: + assert await search() == [] + diagnostics = caplog.text + assert "metadata" in diagnostics + for secret in ("private-invalid-metadata", "private-valid-content", "private-invalid-content"): + assert secret not in diagnostics + assert len(requests) == 1 + finally: + manager.close() + + +@pytest.mark.parametrize("metadata", [None, {}, {"category": "preference"}]) +def test_search_preserves_valid_metadata(metadata): + manager = HonchoMemoryManager.from_config({"base_url": "http://honcho.test"}) + manager._client.close() + manager._client = HonchoClient(manager._config, transport=httpx.MockTransport(lambda _: httpx.Response(200, json=[{"content": "Synthetic content", "metadata": metadata}]))) + try: + result = manager.search("synthetic query", user_id="synthetic-user") + assert result[0]["content"] == "Synthetic content" + assert result[0]["category"] == ("preference" if metadata else "memory") + finally: + manager.close()