mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
* fix: rebuild todo reminders after context compaction * chore: remove implementation plan from PR * refactor: share todo reminder message name * docs: keep agent guidance within CI size budgets --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
150 lines
7.4 KiB
Python
150 lines
7.4 KiB
Python
"""Todo context survives compaction through state, not stale reminder snapshots."""
|
|
|
|
from copy import deepcopy
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from langchain.agents import create_agent
|
|
from langchain_core.language_models import BaseChatModel
|
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
from pydantic import PrivateAttr
|
|
|
|
from deerflow.agents.middlewares.dynamic_context_middleware import _DYNAMIC_CONTEXT_REMINDER_KEY
|
|
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware
|
|
from deerflow.agents.middlewares.todo_middleware import TODO_REMINDER_MESSAGE_NAME, TodoMiddleware
|
|
from deerflow.agents.thread_state import ThreadState
|
|
|
|
|
|
def _reminder(msg_id: str) -> HumanMessage:
|
|
return HumanMessage(
|
|
id=msg_id,
|
|
name=TODO_REMINDER_MESSAGE_NAME,
|
|
content="STALE TODO: [pending] Inspect the Gateway routes",
|
|
additional_kwargs={"hide_from_ui": True},
|
|
)
|
|
|
|
|
|
def _todos() -> list[dict[str, str]]:
|
|
return [
|
|
{"content": "Inspect the Gateway routes", "status": "completed"},
|
|
{"content": "Document the authentication endpoints", "status": "completed"},
|
|
]
|
|
|
|
|
|
def _messages(*, visible_write_todos: bool = False) -> list:
|
|
name = "write_todos" if visible_write_todos else "read_file"
|
|
args = {"todos": _todos()} if visible_write_todos else {"path": "backend/app/gateway/routers/auth.py"}
|
|
return [
|
|
SystemMessage(content="Document the project accurately.", id="system"),
|
|
HumanMessage(content="Document the Gateway routes.", id="old-user"),
|
|
_reminder("old-todo"),
|
|
AIMessage(content="I inspected the route definitions.", id="old-ai"),
|
|
HumanMessage(content="Include the authentication endpoints.", id="current-user"),
|
|
AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": "recent-call"}], id="recent-ai"),
|
|
ToolMessage(content="Current endpoint documentation is ready.", tool_call_id="recent-call", name=name, id="recent-tool"),
|
|
_reminder("recent-todo"),
|
|
]
|
|
|
|
|
|
def _middleware(*, trigger: int = 4, text: str = "Gateway route documentation is ready.", hooks=None):
|
|
model = MagicMock()
|
|
model.invoke.return_value = SimpleNamespace(text=text)
|
|
model.ainvoke = AsyncMock(return_value=SimpleNamespace(text=text))
|
|
model.with_config.return_value = model
|
|
return DeerFlowSummarizationMiddleware(model=model, trigger=("messages", trigger), keep=("messages", 3), token_counter=len, before_summarization=hooks)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
@pytest.mark.parametrize("force", [False, True], ids=["automatic", "manual"])
|
|
async def test_compaction_excludes_todo_reminders_from_summary_and_retained_context(asynchronous, force):
|
|
events = []
|
|
middleware = _middleware(trigger=1000 if force else 4, hooks=[events.append])
|
|
messages = _messages()
|
|
memory = HumanMessage(content="User prefers detailed API examples.", id="memory", additional_kwargs={"hide_from_ui": True, _DYNAMIC_CONTEXT_REMINDER_KEY: True})
|
|
# An unrelated hidden message must not be removed with the todo reminders.
|
|
messages.insert(1, memory)
|
|
state = {"messages": messages, "todos": _todos()}
|
|
original = deepcopy(state)
|
|
runtime = SimpleNamespace(context={"thread_id": "docs", "run_id": "docs-run"})
|
|
|
|
result = await middleware.acompact_state(state, runtime, force=force) if asynchronous else middleware.compact_state(state, runtime, force=force)
|
|
|
|
assert result is not None
|
|
assert [m.id for m in result.messages_to_summarize] == ["old-user", "old-ai"]
|
|
assert [m.id for m in result.preserved_messages] == ["system", "memory", "current-user", "recent-ai", "recent-tool"]
|
|
call = middleware.model.ainvoke.call_args if asynchronous else middleware.model.invoke.call_args
|
|
assert "STALE TODO" not in str(call)
|
|
assert "Gateway routes" in str(call)
|
|
assert events[0].messages_to_summarize == result.messages_to_summarize
|
|
assert events[0].preserved_messages == result.preserved_messages
|
|
assert state == original
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
@pytest.mark.parametrize("reason", ["below-trigger", "summary-failed", "no-history"])
|
|
async def test_unsuccessful_compaction_leaves_todo_reminders_and_state_untouched(asynchronous, reason):
|
|
events = []
|
|
middleware = _middleware(trigger=1000 if reason == "below-trigger" else 4, text=" " if reason == "summary-failed" else "summary", hooks=[events.append])
|
|
messages = _messages()
|
|
if reason == "no-history":
|
|
messages = [SystemMessage(content="Document the project.", id="system"), HumanMessage(content="Document auth.", id="user"), _reminder("todo-1"), _reminder("todo-2")]
|
|
state = {"messages": messages, "todos": _todos(), "summary_text": "previous summary"}
|
|
original = deepcopy(state)
|
|
runtime = SimpleNamespace(context={"thread_id": "docs", "run_id": "docs-run"})
|
|
|
|
result = await middleware.abefore_model(state, runtime) if asynchronous else middleware.before_model(state, runtime)
|
|
|
|
assert result is None
|
|
assert state == original
|
|
assert events == []
|
|
|
|
|
|
class _CapturingModel(BaseChatModel):
|
|
_seen: list[list] = PrivateAttr(default_factory=list)
|
|
|
|
@property
|
|
def _llm_type(self):
|
|
return "todo-compaction-test"
|
|
|
|
def bind_tools(self, tools, **kwargs):
|
|
return self
|
|
|
|
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
self._seen.append(list(messages))
|
|
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="The Gateway documentation is complete."))])
|
|
|
|
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
@pytest.mark.parametrize("context", ["rebuild", "empty-todos", "visible-write-todos"])
|
|
async def test_next_model_gets_current_todos_after_compaction(asynchronous, context):
|
|
model = _CapturingModel()
|
|
summary = _middleware()
|
|
graph = create_agent(model=model, middleware=[summary, TodoMiddleware()], state_schema=ThreadState)
|
|
todos = [] if context == "empty-todos" else _todos()
|
|
state = {"messages": _messages(visible_write_todos=context == "visible-write-todos"), "todos": todos}
|
|
runtime_context = {"thread_id": "docs", "run_id": "docs-run"}
|
|
|
|
result = await graph.ainvoke(state, context=runtime_context) if asynchronous else graph.invoke(state, context=runtime_context)
|
|
|
|
assert result["todos"] == todos
|
|
assert result["summary_text"] == "Gateway route documentation is ready."
|
|
assert len(model._seen) == 1
|
|
request = model._seen[0]
|
|
assert all("STALE TODO" not in str(m.content) for m in request)
|
|
reminders = [m for m in request if isinstance(m, HumanMessage) and m.name == TODO_REMINDER_MESSAGE_NAME]
|
|
persisted = [m for m in result["messages"] if isinstance(m, HumanMessage) and m.name == TODO_REMINDER_MESSAGE_NAME]
|
|
assert len(reminders) == len(persisted) == (1 if context == "rebuild" else 0)
|
|
if reminders:
|
|
assert "[completed] Inspect the Gateway routes" in reminders[0].content
|
|
assert "[completed] Document the authentication endpoints" in reminders[0].content
|
|
assert reminders[0].additional_kwargs["hide_from_ui"] is True
|
|
assert {"recent-ai", "recent-tool"}.issubset({m.id for m in request})
|