deer-flow/backend/tests/test_memory_admission_depth.py
lilei 7e64330dd1
fix(memory): avoid recursive tool-argument admission (#7928)
* fix(memory): avoid recursive tool-argument admission

* fix(memory): track argument redaction changes without deep equality
2026-11-06 21:24:57 +08:00

213 lines
9.4 KiB
Python

"""Memory admission must not abort SDK graphs on valid deep tool arguments."""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
from deerflow.agents.factory import create_deerflow_agent
from deerflow.agents.features import RuntimeFeatures
from deerflow.agents.middlewares import memory_middleware
from deerflow.agents.middlewares.pii_redaction_middleware import redact_text
from deerflow.config.memory_config import MemoryConfig
from deerflow.config.pii_redaction_config import PiiRedactionConfig
_EMAIL = "alice@example.com"
_OTHER_EMAIL = "bob@example.com"
class _ScriptedModel(FakeMessagesListChatModel):
def bind_tools(self, tools, **kwargs):
return self
@tool
def _consume(nested: dict) -> str:
"""Consume a nested JSON structure."""
return "done"
def _arguments(depth: int, shape: str = "dict") -> dict:
value = {_EMAIL: _OTHER_EMAIL, "number": 1, "flag": False, "empty": None}
for _ in range(depth):
value = {"nested": value} if shape == "dict" else {"nested": [value]}
# Keep this a valid-JSON regression, not an arbitrary Python-only state.
return json.loads(json.dumps(value))
def _json_depth_limit(maximum: int, shape: str = "dict") -> int:
"""Find an accepted JSON depth without changing interpreter recursion limits."""
low, high = 0, maximum
while low < high:
middle = (low + high + 1) // 2
try:
_arguments(middle, shape)
except RecursionError:
high = middle - 1
else:
low = middle
return low
@pytest.fixture(params=["shallow", "json-boundary"])
def depth(request):
# A fixed 1100 may exceed JSON limits and skip on some interpreters.
# Probe the accepted boundary so every deep case actually runs.
return request.param
def _leaf(value: dict, depth: int, shape: str = "dict") -> dict:
for _ in range(depth):
value = value["nested"]
if shape == "mixed":
value = value[0]
return value
def _policy(policy: str) -> PiiRedactionConfig | None:
return None if policy == "omitted" else PiiRedactionConfig(enabled=policy != "disabled", redact_email=policy != "email-disabled", token_secret="memory-depth-regression-secret")
@pytest.mark.parametrize("mode", ["sync", "async"])
@pytest.mark.parametrize("memory", [False, True])
@pytest.mark.parametrize("policy", ["omitted", "disabled", "enabled", "email-disabled"])
@pytest.mark.asyncio
async def test_sdk_completion_with_deep_memory_arguments(monkeypatch, mode, depth, memory, policy):
depth = 10 if depth == "shallow" else _json_depth_limit(10_000)
args = _arguments(depth)
original = json.dumps(args)
attempt = AIMessage(content="", id="historical-ai", tool_calls=[{"name": "_consume", "args": args, "id": "old-call"}])
messages = [HumanMessage("Analyze."), attempt, ToolMessage(content="done", tool_call_id="old-call"), HumanMessage("Summarize.")]
manager = SimpleNamespace(add=Mock(), aadd=AsyncMock())
monkeypatch.setattr(memory_middleware, "get_memory_manager", lambda: manager)
config = _policy(policy)
graph = create_deerflow_agent(
_ScriptedModel(responses=[AIMessage(content="Task complete.")]),
tools=[_consume],
features=RuntimeFeatures(sandbox=False, memory=memory, memory_config=MemoryConfig(enabled=True), loop_detection=False),
checkpointer=None,
**({} if policy == "omitted" else {"pii_redaction_config": config}),
)
context = {"thread_id": "memory-depth", "user_id": "memory-owner"}
result = graph.invoke({"messages": messages}, context=context) if mode == "sync" else await graph.ainvoke({"messages": messages}, context=context)
assert result["messages"][-1].content == "Task complete."
assert json.dumps(attempt.tool_calls[0]["args"]) == original
selected, unused = (manager.add, manager.aadd) if mode == "sync" else (manager.aadd, manager.add)
unused.assert_not_called()
assert selected.call_count == int(memory)
if not memory:
return
call = selected.call_args
assert call.args[0] == "memory-depth"
assert call.kwargs["user_id"] == "memory-owner"
queued = next(message for message in call.args[1] if message.id == "historical-ai")
assert queued.tool_calls[0]["id"] == "old-call"
assert queued.tool_calls[0]["name"] == "_consume"
expected = {redact_text(_EMAIL, config): redact_text(_OTHER_EMAIL, config)} if policy == "enabled" else {_EMAIL: _OTHER_EMAIL}
assert _leaf(queued.tool_calls[0]["args"], depth) == {**expected, "number": 1, "flag": False, "empty": None}
assert (queued is attempt) is (policy != "enabled")
@pytest.mark.parametrize("shape,depth", [("dict", 1100), ("mixed", 550)])
def test_deep_arguments_are_redacted_without_mutating_messages(shape, depth):
depth = _json_depth_limit(depth, shape)
args = _arguments(depth, shape)
original = json.dumps(args)
message = AIMessage(content="", id="ai-1", tool_calls=[{"name": "lookup", "args": args, "id": "call-1"}])
config = _policy("enabled")
queued = memory_middleware.redact_queued_messages([message], config)[0]
assert queued.id == message.id
assert queued is not message
redacted = queued.tool_calls[0]["args"]
assert queued.tool_calls[0]["id"] == "call-1"
leaf = _leaf(redacted, depth, shape)
assert leaf == {redact_text(_EMAIL, config): redact_text(_OTHER_EMAIL, config), "number": 1, "flag": False, "empty": None}
assert json.dumps(args) == original
leaf["new"] = "queued only"
assert "new" not in _leaf(args, depth, shape)
@pytest.mark.parametrize("policy", ["omitted", "disabled"])
def test_disabled_redaction_returns_an_independent_message_list(policy):
message = AIMessage(content="done", tool_calls=[{"name": "lookup", "args": _arguments(_json_depth_limit(1100)), "id": "call-1"}])
messages = [message]
queued = memory_middleware.redact_queued_messages(messages, _policy(policy))
assert queued is not messages
assert queued[0] is message
queued.clear()
assert messages == [message]
def test_redaction_keeps_shared_branches_and_key_collision_order():
config = _policy("enabled")
token = redact_text(_EMAIL, config)
shared = {_EMAIL: _OTHER_EMAIL}
args = {"contacts": {_EMAIL: "first", token: "last"}, "branches": [shared, shared]}
message = AIMessage(content="", tool_calls=[{"name": "lookup", "args": args, "id": "call-1"}])
queued = memory_middleware.redact_queued_messages([message], config)[0].tool_calls[0]["args"]
assert queued["contacts"] == {token: "last"}
assert queued["branches"] == [{token: redact_text(_OTHER_EMAIL, config)}] * 2
queued["branches"][0]["new"] = "queued only"
assert shared == {_EMAIL: _OTHER_EMAIL}
assert "new" not in queued["branches"][1]
@pytest.mark.parametrize("shape", ["dict", "list"])
def test_non_json_cycles_fail_without_an_unredacted_fallback(shape):
cyclic = {} if shape == "dict" else []
if shape == "dict":
cyclic["cycle"] = cyclic
else:
cyclic.append(cyclic)
message = AIMessage(content="", tool_calls=[{"name": "lookup", "args": {"nested": cyclic, "email": _EMAIL}, "id": "call-1"}])
with pytest.raises(ValueError, match="[Cc]ycl"):
memory_middleware.redact_queued_messages([message], _policy("enabled"))
def test_deep_arguments_without_pii_matches_keep_message_identity():
args = {"text": "ordinary text"}
for _ in range(_json_depth_limit(10_000)):
args = {"nested": args}
args = json.loads(json.dumps(args))
message = AIMessage(content="done", tool_calls=[{"name": "lookup", "args": args, "id": "call-1"}])
queued = memory_middleware.redact_queued_messages([message], _policy("enabled"))
assert queued[0] is message
@pytest.mark.parametrize("view", ["provider-raw", "legacy"])
@pytest.mark.parametrize("redact_email", [False, True])
def test_raw_argument_admission_at_json_depth_boundary(view, redact_email):
depth = _json_depth_limit(10_000)
args = _arguments(depth)
original = json.dumps(args)
raw_call = {"name": "lookup", "arguments": args}
# Restored messages can retain structured raw arguments separately from
# their parsed view. Both carriers must follow the same redaction policy.
field, raw = ("tool_calls", [{"id": "call-1", "type": "function", "function": raw_call}]) if view == "provider-raw" else ("function_call", raw_call)
message = AIMessage(content="done", id="raw-ai", tool_calls=[{"name": "lookup", "args": {}, "id": "call-1"}], additional_kwargs={field: raw})
config = PiiRedactionConfig(enabled=True, redact_email=redact_email, token_secret="memory-depth-regression-secret")
queued = memory_middleware.redact_queued_messages([message], config)[0]
result = queued.additional_kwargs[field][0]["function"]["arguments"] if view == "provider-raw" else queued.additional_kwargs[field]["arguments"]
expected = {redact_text(_EMAIL, config): redact_text(_OTHER_EMAIL, config)} if redact_email else {_EMAIL: _OTHER_EMAIL}
assert _leaf(result, depth) == {**expected, "number": 1, "flag": False, "empty": None}
assert queued.id == message.id and queued.tool_calls[0]["id"] == "call-1"
assert (queued is message) is not redact_email
assert json.dumps(args) == original