mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06: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
|
### 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,
|
Optional `pii_redaction.enabled` redacts detected identifiers in user messages,
|
||||||
remote tool results, compaction input, reinjected summaries, and configured
|
remote tool results, compaction input, reinjected summaries, and configured
|
||||||
LLM title input. It is off by default. Existing summary placeholders reserve
|
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
|
exact isolated interpreter/module/provider launcher generated by
|
||||||
`deerflow.capabilities.business`, including the exact credential environment key
|
`deerflow.capabilities.business`, including the exact credential environment key
|
||||||
set. Do not allow arbitrary Python commands, trust manifest metadata to bypass
|
set. Do not allow arbitrary Python commands, trust manifest metadata to bypass
|
||||||
execution policy, or put credentials into tool schemas. Existing admin checks,
|
execution policy, or put credentials into tool schemas. Keep admin checks, masked edits, atomic configuration writes and MCP cache reloads.
|
||||||
masked edits, atomic configuration writes and MCP cache reloads remain owners.
|
|
||||||
|
|
||||||
Memory shutdown resolves hot-reloaded config and the backend, flushes, then
|
Memory shutdown resolves hot-reloaded config and the backend, flushes, then
|
||||||
closes as one `await_drained` operation. Keep config resolution inside the
|
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
|
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
|
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
|
## Configuration
|
||||||
|
|
||||||
Summarization is configured in `config.yaml` under the `summarization` key:
|
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
|
before escaping/wrapping; pass `trim_tokens_to_summarize=None` to avoid the
|
||||||
LangChain default.
|
LangChain default.
|
||||||
|
|
||||||
|
Todo compaction: [contract](../../../../../docs/summarization.md#todo-reminders).
|
||||||
|
|
||||||
Delegation verdicts are untrusted: revalidate persisted values, ignore malformed
|
Delegation verdicts are untrusted: revalidate persisted values, ignore malformed
|
||||||
ones, and treat completed work as reusable evidence rather than acceptance.
|
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.dynamic_context_middleware import is_dynamic_context_reminder
|
||||||
from deerflow.agents.middlewares.message_utils import is_genuine_user_message
|
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.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.app_config import get_app_config
|
||||||
from deerflow.config.summarization_config import DEFAULT_KEEP
|
from deerflow.config.summarization_config import DEFAULT_KEEP
|
||||||
from deerflow.config.task_continuity_config import TaskContinuityConfig
|
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):
|
if not force and not self._should_summarize(trigger_messages, total_tokens):
|
||||||
return None
|
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)
|
cutoff_index = self._determine_cutoff_index(messages)
|
||||||
if cutoff_index <= 0:
|
if cutoff_index <= 0:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -31,6 +31,8 @@ from langgraph.runtime import Runtime
|
|||||||
|
|
||||||
from deerflow.agents.thread_state import ThreadState
|
from deerflow.agents.thread_state import ThreadState
|
||||||
|
|
||||||
|
TODO_REMINDER_MESSAGE_NAME = "todo_reminder"
|
||||||
|
|
||||||
|
|
||||||
def _todos_in_messages(messages: list[Any]) -> bool:
|
def _todos_in_messages(messages: list[Any]) -> bool:
|
||||||
"""Return True if any AIMessage in *messages* contains a write_todos tool call."""
|
"""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:
|
def _reminder_in_messages(messages: list[Any]) -> bool:
|
||||||
"""Return True if a todo_reminder HumanMessage is already present in *messages*."""
|
"""Return True if a todo_reminder HumanMessage is already present in *messages*."""
|
||||||
for msg 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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -150,7 +152,7 @@ class TodoMiddleware(TodoListMiddleware):
|
|||||||
# Inject a reminder as a HumanMessage so the model stays aware.
|
# Inject a reminder as a HumanMessage so the model stays aware.
|
||||||
formatted = _format_todos(todos)
|
formatted = _format_todos(todos)
|
||||||
reminder = HumanMessage(
|
reminder = HumanMessage(
|
||||||
name="todo_reminder",
|
name=TODO_REMINDER_MESSAGE_NAME,
|
||||||
additional_kwargs={"hide_from_ui": True},
|
additional_kwargs={"hide_from_ui": True},
|
||||||
content=(
|
content=(
|
||||||
"<system_reminder>\n"
|
"<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,
|
ModelLengthFinishReasonMiddleware,
|
||||||
)
|
)
|
||||||
from deerflow.agents.middlewares.todo_middleware import (
|
from deerflow.agents.middlewares.todo_middleware import (
|
||||||
|
TODO_REMINDER_MESSAGE_NAME,
|
||||||
TodoMiddleware,
|
TodoMiddleware,
|
||||||
_format_todos,
|
_format_todos,
|
||||||
_has_tool_call_intent_or_error,
|
_has_tool_call_intent_or_error,
|
||||||
@ -28,7 +29,7 @@ def _ai_with_write_todos():
|
|||||||
|
|
||||||
|
|
||||||
def _reminder_msg():
|
def _reminder_msg():
|
||||||
return HumanMessage(name="todo_reminder", content="reminder")
|
return HumanMessage(name=TODO_REMINDER_MESSAGE_NAME, content="reminder")
|
||||||
|
|
||||||
|
|
||||||
class _CapturingFakeMessagesListChatModel(FakeMessagesListChatModel):
|
class _CapturingFakeMessagesListChatModel(FakeMessagesListChatModel):
|
||||||
@ -173,7 +174,7 @@ class TestBeforeModel:
|
|||||||
msgs = result["messages"]
|
msgs = result["messages"]
|
||||||
assert len(msgs) == 1
|
assert len(msgs) == 1
|
||||||
assert isinstance(msgs[0], HumanMessage)
|
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):
|
def test_reminder_contains_formatted_todos(self):
|
||||||
mw = TodoMiddleware()
|
mw = TodoMiddleware()
|
||||||
@ -198,7 +199,7 @@ class TestAbeforeModel:
|
|||||||
}
|
}
|
||||||
result = asyncio.run(mw.abefore_model(state, _make_runtime()))
|
result = asyncio.run(mw.abefore_model(state, _make_runtime()))
|
||||||
assert result is not None
|
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):
|
def _todo_completion_reminders(messages):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user