mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(memory): exclude hidden framework messages from Honcho writes * fix(memory): isolate hidden-message hook failures
216 lines
8.9 KiB
Python
216 lines
8.9 KiB
Python
"""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
|