mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
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:
parent
9e08568455
commit
97b12b1381
@ -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).
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
215
backend/tests/test_honcho_hidden_messages.py
Normal file
215
backend/tests/test_honcho_hidden_messages.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user