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
This commit is contained in:
Fish 2026-10-22 16:51:47 +08:00 committed by GitHub
parent 9e08568455
commit 97b12b1381
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 297 additions and 3 deletions

View File

@ -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).

View File

@ -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.

View File

@ -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

View File

@ -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"]

View File

@ -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