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:
Keswyk Cheung 2026-09-22 17:45:01 +08:00 committed by GitHub
parent dde0595ec8
commit ef21855b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 188 additions and 7 deletions

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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"

View 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})

View File

@ -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):