mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 05:56:18 +00:00
fix: discard stale todo reminders during context compaction (#5614)
* 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>
This commit is contained in:
parent
dde0595ec8
commit
ef21855b8d
@ -1495,6 +1495,12 @@ When your role lacks `runs:create`, the Web UI rejects a new task or `/goal <com
|
||||
|
||||
### Manual Context Compaction
|
||||
|
||||
Automatic and manual compaction exclude old todo reminder messages from both the
|
||||
summary input and retained context. The current todo list stays in thread state.
|
||||
In planning mode, if the original `write_todos` call is no longer visible,
|
||||
DeerFlow adds a reminder using the latest task statuses before the next model
|
||||
call. Skipped or failed compaction leaves the existing messages unchanged.
|
||||
|
||||
Optional `pii_redaction.enabled` redacts detected identifiers in user messages,
|
||||
remote tool results, compaction input, reinjected summaries, and configured
|
||||
LLM title input. It is off by default. Existing summary placeholders reserve
|
||||
|
||||
@ -5,8 +5,7 @@ credential fields and creates a normal MCP connection. The MCP API accepts the
|
||||
exact isolated interpreter/module/provider launcher generated by
|
||||
`deerflow.capabilities.business`, including the exact credential environment key
|
||||
set. Do not allow arbitrary Python commands, trust manifest metadata to bypass
|
||||
execution policy, or put credentials into tool schemas. Existing admin checks,
|
||||
masked edits, atomic configuration writes and MCP cache reloads remain owners.
|
||||
execution policy, or put credentials into tool schemas. Keep admin checks, masked edits, atomic configuration writes and MCP cache reloads.
|
||||
|
||||
Memory shutdown resolves hot-reloaded config and the backend, flushes, then
|
||||
closes as one `await_drained` operation. Keep config resolution inside the
|
||||
|
||||
@ -14,6 +14,20 @@ The summarization feature uses LangChain's `SummarizationMiddleware` to monitor
|
||||
4. Maintains AI/Tool message pairs together for context continuity
|
||||
5. Stores the summary in `ThreadState.summary_text` and projects it ephemerally through durable context data
|
||||
|
||||
## Todo reminders
|
||||
|
||||
Compaction filters `HumanMessage(name="todo_reminder")` snapshots after the
|
||||
trigger check and before selecting the retained tail. They enter neither summary
|
||||
generation/pre-compaction hooks nor retained messages, while `state["todos"]`
|
||||
remains unchanged. Only a successful compaction commits the removal; no-op and
|
||||
failure paths keep the original state. The following `TodoMiddleware.before_model`
|
||||
rebuilds one reminder from current todos when no `write_todos` call is still
|
||||
visible; empty todos need no reminder. Both automatic and manual compaction use
|
||||
this shared preparation path. Coverage: `tests/test_todo_compaction.py`.
|
||||
`todo_middleware.py::TODO_REMINDER_MESSAGE_NAME` owns the backend message name;
|
||||
the producer, presence check, and compaction filter share it. Its value remains
|
||||
`todo_reminder` for compatibility with the frontend's hidden-message filtering.
|
||||
|
||||
## Configuration
|
||||
|
||||
Summarization is configured in `config.yaml` under the `summarization` key:
|
||||
|
||||
@ -7,6 +7,8 @@ mixed windows keep normal anchoring and final-message fallback. Budget raw text
|
||||
before escaping/wrapping; pass `trim_tokens_to_summarize=None` to avoid the
|
||||
LangChain default.
|
||||
|
||||
Todo compaction: [contract](../../../../../docs/summarization.md#todo-reminders).
|
||||
|
||||
Delegation verdicts are untrusted: revalidate persisted values, ignore malformed
|
||||
ones, and treat completed work as reusable evidence rather than acceptance.
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ from langgraph.runtime import Runtime
|
||||
from deerflow.agents.middlewares.dynamic_context_middleware import is_dynamic_context_reminder
|
||||
from deerflow.agents.middlewares.message_utils import is_genuine_user_message
|
||||
from deerflow.agents.middlewares.pii_redaction_middleware import redact_text
|
||||
from deerflow.agents.middlewares.todo_middleware import TODO_REMINDER_MESSAGE_NAME
|
||||
from deerflow.config.app_config import get_app_config
|
||||
from deerflow.config.summarization_config import DEFAULT_KEEP
|
||||
from deerflow.config.task_continuity_config import TaskContinuityConfig
|
||||
@ -576,6 +577,13 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
|
||||
if not force and not self._should_summarize(trigger_messages, total_tokens):
|
||||
return None
|
||||
|
||||
# Todo reminders are snapshots of state["todos"], not conversation history.
|
||||
# Exclude them before partitioning so neither the summary nor the retained
|
||||
# tail contains stale task statuses. TodoMiddleware restores current context
|
||||
# when needed before the next model call. Keep state untouched if compaction
|
||||
# is skipped or summary generation fails.
|
||||
messages = [message for message in messages if not (isinstance(message, HumanMessage) and message.name == TODO_REMINDER_MESSAGE_NAME)]
|
||||
|
||||
cutoff_index = self._determine_cutoff_index(messages)
|
||||
if cutoff_index <= 0:
|
||||
return None
|
||||
|
||||
@ -31,6 +31,8 @@ from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.agents.thread_state import ThreadState
|
||||
|
||||
TODO_REMINDER_MESSAGE_NAME = "todo_reminder"
|
||||
|
||||
|
||||
def _todos_in_messages(messages: list[Any]) -> bool:
|
||||
"""Return True if any AIMessage in *messages* contains a write_todos tool call."""
|
||||
@ -45,7 +47,7 @@ def _todos_in_messages(messages: list[Any]) -> bool:
|
||||
def _reminder_in_messages(messages: list[Any]) -> bool:
|
||||
"""Return True if a todo_reminder HumanMessage is already present in *messages*."""
|
||||
for msg in messages:
|
||||
if isinstance(msg, HumanMessage) and getattr(msg, "name", None) == "todo_reminder":
|
||||
if isinstance(msg, HumanMessage) and getattr(msg, "name", None) == TODO_REMINDER_MESSAGE_NAME:
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -150,7 +152,7 @@ class TodoMiddleware(TodoListMiddleware):
|
||||
# Inject a reminder as a HumanMessage so the model stays aware.
|
||||
formatted = _format_todos(todos)
|
||||
reminder = HumanMessage(
|
||||
name="todo_reminder",
|
||||
name=TODO_REMINDER_MESSAGE_NAME,
|
||||
additional_kwargs={"hide_from_ui": True},
|
||||
content=(
|
||||
"<system_reminder>\n"
|
||||
|
||||
149
backend/tests/test_todo_compaction.py
Normal file
149
backend/tests/test_todo_compaction.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""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})
|
||||
@ -14,6 +14,7 @@ from deerflow.agents.middlewares.model_length_finish_reason_middleware import (
|
||||
ModelLengthFinishReasonMiddleware,
|
||||
)
|
||||
from deerflow.agents.middlewares.todo_middleware import (
|
||||
TODO_REMINDER_MESSAGE_NAME,
|
||||
TodoMiddleware,
|
||||
_format_todos,
|
||||
_has_tool_call_intent_or_error,
|
||||
@ -28,7 +29,7 @@ def _ai_with_write_todos():
|
||||
|
||||
|
||||
def _reminder_msg():
|
||||
return HumanMessage(name="todo_reminder", content="reminder")
|
||||
return HumanMessage(name=TODO_REMINDER_MESSAGE_NAME, content="reminder")
|
||||
|
||||
|
||||
class _CapturingFakeMessagesListChatModel(FakeMessagesListChatModel):
|
||||
@ -173,7 +174,7 @@ class TestBeforeModel:
|
||||
msgs = result["messages"]
|
||||
assert len(msgs) == 1
|
||||
assert isinstance(msgs[0], HumanMessage)
|
||||
assert msgs[0].name == "todo_reminder"
|
||||
assert msgs[0].name == TODO_REMINDER_MESSAGE_NAME
|
||||
|
||||
def test_reminder_contains_formatted_todos(self):
|
||||
mw = TodoMiddleware()
|
||||
@ -198,7 +199,7 @@ class TestAbeforeModel:
|
||||
}
|
||||
result = asyncio.run(mw.abefore_model(state, _make_runtime()))
|
||||
assert result is not None
|
||||
assert result["messages"][0].name == "todo_reminder"
|
||||
assert result["messages"][0].name == TODO_REMINDER_MESSAGE_NAME
|
||||
|
||||
|
||||
def _todo_completion_reminders(messages):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user