From 97b12b1381887e0cb209e24c939bc52c41a91cc7 Mon Sep 17 00:00:00 2001 From: Fish <413281162@qq.com> Date: Thu, 22 Oct 2026 16:51:47 +0800 Subject: [PATCH] fix(memory): exclude hidden framework messages from Honcho writes (#7159) * fix(memory): exclude hidden framework messages from Honcho writes * fix(memory): isolate hidden-message hook failures --- README.md | 4 + .../harness/deerflow/agents/memory/AGENTS.md | 10 + .../memory/backends/honcho/honcho_manager.py | 16 +- .../blocking_io/test_honcho_memory_backend.py | 55 ++++- backend/tests/test_honcho_hidden_messages.py | 215 ++++++++++++++++++ 5 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_honcho_hidden_messages.py diff --git a/README.md b/README.md index 59d3d1b916..352a92b1c7 100644 --- a/README.md +++ b/README.md @@ -3041,6 +3041,10 @@ API). It builds user-model memory — long-term preferences and a cross-session working representation — on Honcho's server side, so the backend makes no LLM calls locally. Each user gets an isolated workspace derived from `user_id`; a missing user id fails closed instead of falling back to a shared workspace. +Framework-hidden user messages are excluded from Honcho writes; the host's +human-input filter preserves valid user clarification answers carried in hidden messages. +If that filter raises an exception, only the affected hidden message is skipped; +other messages in the batch can still be captured. Fact CRUD and Settings-page fact editing are not available for this backend. See the [Honcho backend guide](backend/packages/harness/deerflow/agents/memory/backends/honcho/README.md). diff --git a/backend/packages/harness/deerflow/agents/memory/AGENTS.md b/backend/packages/harness/deerflow/agents/memory/AGENTS.md index c1e95543e5..71a2ff5bab 100644 --- a/backend/packages/harness/deerflow/agents/memory/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/memory/AGENTS.md @@ -351,6 +351,16 @@ Honcho is a remote HTTP adapter for user-model memory. It creates one workspace per resolved `user_id`. A missing user fails closed to no memory. Its async methods offload synchronous HTTP work with `asyncio.to_thread`. +Honcho's `from_config` consumes the `should_keep_hidden_message` host hook. +Its write path drops hidden human messages unless that hook accepts them; +the host default keeps valid user clarification answers and rejects framework context. +Without a callable hook, standalone construction drops all hidden human messages. +If the hook raises an `Exception`, skip only that hidden message and continue the batch. +Log a generic warning without message metadata, content or exception text. +Keep this boundary around the hook; do not change HTTP write errors or catch `BaseException`. +Keep the filter inside `add` so sync, async and pre-compaction writes share it; +`tests/test_honcho_hidden_messages.py` checks the real HTTP payloads, middleware and host factory; +`tests/blocking_io/test_honcho_memory_backend.py` keeps the filter hook off the event loop. The default read failure policy logs and returns no results. `failure_policy.read: fail_closed` rethrows recall failures. diff --git a/backend/packages/harness/deerflow/agents/memory/backends/honcho/honcho_manager.py b/backend/packages/harness/deerflow/agents/memory/backends/honcho/honcho_manager.py index 868c219262..d8a2c73e48 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/honcho/honcho_manager.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/honcho/honcho_manager.py @@ -100,6 +100,7 @@ class HonchoMemoryManager(MemoryManager): _config: HonchoConfig = PrivateAttr(default=None) _client: Any = PrivateAttr(default=None) + _should_keep_hidden_message: Any = PrivateAttr(default=None) supports_search: ClassVar[bool] = True # Honcho's server-side deriver extracts facts/representation from add() @@ -127,7 +128,10 @@ class HonchoMemoryManager(MemoryManager): Connectivity is deliberately NOT probed: a temporarily unreachable Honcho must not block Gateway startup; reads degrade per ``failure_policy.read``. """ - return cls(backend_config=backend_config, mode=mode) + manager = cls(backend_config=backend_config, mode=mode) + hidden_filter = host_hooks.get("should_keep_hidden_message") + manager._should_keep_hidden_message = hidden_filter if callable(hidden_filter) else None + return manager @classmethod def read_failures_are_fatal_for_config( @@ -184,6 +188,16 @@ class HonchoMemoryManager(MemoryManager): outgoing: list[dict[str, str]] = [] for message in messages or []: msg_type = getattr(message, "type", None) + if msg_type == "human": + additional_kwargs = getattr(message, "additional_kwargs", {}) or {} + keep_hidden = self._should_keep_hidden_message + if additional_kwargs.get("hide_from_ui"): + try: + if keep_hidden is None or not keep_hidden(additional_kwargs): + continue + except Exception: + logger.warning("honcho memory: hidden message filter failed; skipping hidden message") + continue text = _content_to_text(getattr(message, "content", "")).strip() if not text: continue diff --git a/backend/tests/blocking_io/test_honcho_memory_backend.py b/backend/tests/blocking_io/test_honcho_memory_backend.py index 2b8e2a96ea..e175ac0900 100644 --- a/backend/tests/blocking_io/test_honcho_memory_backend.py +++ b/backend/tests/blocking_io/test_honcho_memory_backend.py @@ -44,8 +44,8 @@ class _BlockingFakeClient: pass -def _manager(tmp_path: Path) -> HonchoMemoryManager: - mgr = HonchoMemoryManager.from_config({"base_url": "http://honcho.test"}) +def _manager(tmp_path: Path, **host_hooks: Any) -> HonchoMemoryManager: + mgr = HonchoMemoryManager.from_config({"base_url": "http://honcho.test"}, **host_hooks) mgr._client = _BlockingFakeClient(tmp_path) return mgr @@ -73,3 +73,54 @@ async def test_async_honcho_operations_do_not_block_event_loop( ) == [] ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("keep_hidden", [False, True]) +async def test_hidden_message_hook_runs_off_the_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, keep_hidden: bool) -> None: + probe = tmp_path / "hidden-filter.txt" + seen = [] + outgoing = [] + + def should_keep(metadata: dict[str, Any]) -> bool: + probe.write_text("called", encoding="utf-8") + seen.append(metadata) + return keep_hidden + + manager = _manager(tmp_path, should_keep_hidden_message=should_keep) + + def add_messages(workspace: str, session_id: str, messages: list[Any]) -> None: + manager._client._block() + outgoing.extend(messages) + + monkeypatch.setattr(manager._client, "add_messages", add_messages) + metadata = {"hide_from_ui": True} + hidden = HumanMessage("hidden context", additional_kwargs=metadata) + await manager.aadd("thread-1", [hidden, AIMessage("reply")], user_id="alice") + + assert seen == [metadata] + assert probe.read_text(encoding="utf-8") == "called" + assert [item["content"] for item in outgoing] == (["hidden context", "reply"] if keep_hidden else ["reply"]) + + +@pytest.mark.asyncio +async def test_hidden_hook_failure_keeps_visible_writes_off_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + probe = tmp_path / "failed-hidden-filter.txt" + outgoing = [] + + def keep(metadata: dict[str, Any]) -> bool: + probe.write_text("called", encoding="utf-8") + raise RuntimeError("private-filter-error") + + manager = _manager(tmp_path, should_keep_hidden_message=keep) + + def add_messages(workspace: str, session_id: str, messages: list[Any]) -> None: + manager._client._block() + outgoing.extend(messages) + + monkeypatch.setattr(manager._client, "add_messages", add_messages) + messages = [HumanMessage("before"), HumanMessage("hidden", additional_kwargs={"hide_from_ui": True}), HumanMessage("after"), AIMessage("reply")] + await manager.aadd("thread-1", messages, user_id="alice") + + assert probe.read_text(encoding="utf-8") == "called" + assert [item["content"] for item in outgoing] == ["before", "after", "reply"] diff --git a/backend/tests/test_honcho_hidden_messages.py b/backend/tests/test_honcho_hidden_messages.py new file mode 100644 index 0000000000..4f02ec9ff2 --- /dev/null +++ b/backend/tests/test_honcho_hidden_messages.py @@ -0,0 +1,215 @@ +"""Honcho capture excludes framework context while preserving human answers.""" + +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest +from langchain_core.messages import AIMessage, HumanMessage, messages_to_dict +from langgraph.runtime import Runtime + +from deerflow.agents.memory import manager as memory_manager +from deerflow.agents.memory.backends.honcho import honcho_manager +from deerflow.agents.memory.backends.honcho.client import HonchoClient +from deerflow.agents.memory.manager import _host_default_should_keep_hidden_message +from deerflow.agents.middlewares import memory_middleware +from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware +from deerflow.agents.middlewares.todo_middleware import TodoMiddleware +from deerflow.config.memory_config import MemoryConfig + + +@pytest.fixture +def capture(monkeypatch): + requests = [] + managers = [] + + def record(request): + requests.append(request) + return httpx.Response(200, json={}) + + monkeypatch.setattr(honcho_manager, "HonchoClient", lambda config: HonchoClient(config, transport=httpx.MockTransport(record))) + + def create(hook=_host_default_should_keep_hidden_message, mode="middleware"): + manager = honcho_manager.HonchoMemoryManager.from_config( + {"base_url": "http://honcho.test", "workspace_overrides": {"alice": "test-workspace"}, "user_peer_overrides": {"alice": "user-peer"}}, + mode=mode, + should_keep_hidden_message=hook, + ) + managers.append(manager) + return manager + + yield create, requests + for manager in managers: + manager.close() + + +def _write(manager, method, messages): + result = getattr(manager, method)("thread-1", messages, user_id="alice") + if method == "aadd": + asyncio.run(result) + + +def _reminder(): + update = TodoMiddleware().before_model({"todos": [{"content": "Check deployment", "status": "pending"}], "messages": []}, Runtime(context={})) + assert update is not None + return update["messages"][0] + + +def _answer(kind): + payload = {"version": 1, "kind": "human_input_response", "source": "ask_clarification", "request_id": "request-1", "response_kind": kind, "value": "Use staging"} + if kind == "option": + payload["option_id"] = "staging" + return HumanMessage("Use staging", additional_kwargs={"hide_from_ui": True, "human_input_response": payload}) + + +@pytest.mark.parametrize("method", ["add", "aadd", "add_nowait"]) +@pytest.mark.parametrize("mode", ["middleware", "tool"]) +def test_http_capture_excludes_reminder_and_preserves_human_answers(capture, method, mode): + create, requests = capture + manager = create(mode=mode) + messages = [HumanMessage("I prefer Python."), _reminder(), _answer("text"), _answer("option"), AIMessage("Understood.")] + original = messages_to_dict(messages) + + _write(manager, method, messages) + + writes = [json.loads(request.content) for request in requests if request.url.path.endswith("/messages")] + assert writes == [ + { + "messages": [ + {"peer_id": "user-peer", "content": "I prefer Python."}, + {"peer_id": "user-peer", "content": "Use staging"}, + {"peer_id": "user-peer", "content": "Use staging"}, + {"peer_id": "deerflow", "content": "Understood."}, + ] + } + ] + assert all(request.url.path.startswith("/v3/workspaces/test-workspace/") for request in requests) + assert messages_to_dict(messages) == original + + +@pytest.mark.parametrize("method", ["add", "aadd", "add_nowait"]) +@pytest.mark.parametrize("hook", [None, "not-callable", _host_default_should_keep_hidden_message]) +def test_framework_only_batch_makes_no_http_requests(capture, method, hook): + create, requests = capture + _write(create(hook=hook), method, [_reminder()]) + assert requests == [] + + +@pytest.mark.parametrize("kind", ["missing-value", "missing-option-id", "unknown-kind"]) +def test_malformed_hidden_clarification_is_not_written(capture, kind): + create, requests = capture + answer = _answer("option") + payload = answer.additional_kwargs["human_input_response"] + if kind == "missing-value": + payload["value"] = " " + elif kind == "missing-option-id": + payload.pop("option_id") + else: + payload["response_kind"] = "other" + _write(create(), "add", [answer]) + assert requests == [] + + +def test_host_hook_controls_hidden_human_admission(capture): + create, requests = capture + seen = [] + + def keep(metadata): + seen.append(metadata) + return metadata.get("keep_for_memory") is True + + messages = [ + HumanMessage("visible"), + HumanMessage("keep", additional_kwargs={"hide_from_ui": True, "keep_for_memory": True}), + _answer("text"), + AIMessage("answer"), + ] + _write(create(hook=keep), "add", messages) + writes = [json.loads(request.content) for request in requests if request.url.path.endswith("/messages")] + assert [item["content"] for item in writes[0]["messages"]] == ["visible", "keep", "answer"] + assert seen == [messages[1].additional_kwargs, messages[2].additional_kwargs] + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_after_agent_does_not_write_persisted_todo_reminder(capture, monkeypatch, asynchronous): + create, requests = capture + manager = create() + monkeypatch.setattr(memory_middleware, "get_memory_manager", lambda: manager) + middleware = MemoryMiddleware(memory_config=MemoryConfig(enabled=True)) + runtime = Runtime(context={"thread_id": "thread-1", "user_id": "alice"}) + state = {"messages": [HumanMessage("hello"), _reminder(), _answer("text"), AIMessage("hi")]} + if asynchronous: + asyncio.run(middleware.aafter_agent(state, runtime)) + else: + middleware.after_agent(state, runtime) + writes = [json.loads(request.content) for request in requests if request.url.path.endswith("/messages")] + assert [item["content"] for item in writes[0]["messages"]] == ["hello", "Use staging", "hi"] + + +@pytest.mark.parametrize("method", ["add", "aadd", "add_nowait"]) +def test_host_factory_preserves_clarification_but_drops_framework_context(capture, monkeypatch, method): + _, requests = capture + config = MemoryConfig(enabled=True, manager_class="honcho", backend_config={"base_url": "http://honcho.test"}) + monkeypatch.setattr(memory_manager, "get_memory_config", lambda: config) + monkeypatch.setattr(memory_manager, "_memory_manager", None) + monkeypatch.setattr(memory_manager, "_backends_cache", None) + monkeypatch.setattr(memory_manager, "_memory_judge_signature", None) + manager = memory_manager.get_memory_manager() + try: + _write(manager, method, [_reminder(), _answer("text"), _answer("option")]) + writes = [json.loads(request.content) for request in requests if request.url.path.endswith("/messages")] + assert [item["content"] for item in writes[0]["messages"]] == ["Use staging", "Use staging"] + finally: + manager.close() + + +@pytest.mark.parametrize("method", ["add", "aadd", "add_nowait"]) +@pytest.mark.parametrize("mode", ["middleware", "tool"]) +def test_hidden_hook_failure_preserves_other_messages(capture, caplog, method, mode): + create, requests = capture + seen = [] + + def keep(metadata): + seen.append(metadata) + if metadata.get("fail_filter"): + raise RuntimeError("private-filter-error") + return _host_default_should_keep_hidden_message(metadata) + + hidden = HumanMessage("private-hidden-content", additional_kwargs={"hide_from_ui": True, "fail_filter": True}) + messages = [HumanMessage("before"), hidden, HumanMessage("after"), _answer("text"), AIMessage("reply")] + original = messages_to_dict(messages) + + _write(create(hook=keep, mode=mode), method, messages) + + writes = [json.loads(request.content) for request in requests if request.url.path.endswith("/messages")] + assert writes == [ + { + "messages": [ + {"peer_id": "user-peer", "content": "before"}, + {"peer_id": "user-peer", "content": "after"}, + {"peer_id": "user-peer", "content": "Use staging"}, + {"peer_id": "deerflow", "content": "reply"}, + ] + } + ] + assert seen == [hidden.additional_kwargs, messages[3].additional_kwargs] + assert messages_to_dict(messages) == original + assert caplog.messages == ["honcho memory: hidden message filter failed; skipping hidden message"] + assert "private-filter-error" not in caplog.text + assert "private-hidden-content" not in caplog.text + + +@pytest.mark.parametrize("method", ["add", "aadd", "add_nowait"]) +def test_only_failing_hidden_hook_makes_no_http_requests(capture, caplog, method): + create, requests = capture + + def keep(metadata): + raise RuntimeError("private-filter-error") + + _write(create(hook=keep), method, [_reminder()]) + + assert requests == [] + assert caplog.messages == ["honcho memory: hidden message filter failed; skipping hidden message"] + assert "private-filter-error" not in caplog.text