TodoListMiddleware
TodoListMiddleware
A LangChain AgentMiddleware that gives agents a structured, persistent task list during complex multi-step operations. DeerFlow 2.0 extends it with two additional robustness layers.
Base class (LangChain)
State extension
PlanningState ⊃ AgentState + { todos: list[Todo] }
Todo = { content: str, status: "pending" | "in_progress" | "completed" }
write_todos tool
The sole interface for agents to manipulate the list. Key properties:
- Atomic full-replace: calling it overwrites the entire list; no append or patch.
- Parallel guard:
after_modeldetects simultaneouswrite_todoscalls and returns errors — because two concurrent overwrites would be ambiguous. - State update is delivered via LangGraph
Command; the tool also appends aToolMessageconfirming the new list.
When agents should use it
| Use it | Skip it |
|---|---|
| Complex objective, ≥ 3 steps | Single straightforward task |
| Explicit multi-phase plan | Trivial / < 3 steps |
| User explicitly requested a plan |
Task lifecycle discipline
pending → in_progress (mark before starting the step)
in_progress → completed (mark immediately on completion — not "soon")
System prompt injection
wrap_model_call appends these rules to every model request before the call reaches the graph. No state field is used; the injection is purely at the model-call boundary.
DeerFlow extension (TodoMiddleware)
DeerFlow subclasses TodoListMiddleware to handle two failure modes that the base class ignores.
Failure mode 1 — Context loss
Problem: When summarization or compaction truncates the message history, the original write_todos call disappears. The model no longer knows it has a todo list.
Fix — before_model hook:
if todos in state
AND no write_todos visible in messages
AND no todo_reminder already injected:
inject HumanMessage(name="todo_reminder")
→ <system_reminder> with formatted todo list
The todo_reminder message is idempotent (checked before injection) and survives further turns as long as it stays in the window.
Failure mode 2 — Premature exit
Problem: The model produces a final response (no tool calls) while incomplete todos remain — it declares victory too early.
Fix — after_model hook (decorated @hook_config(can_jump_to=["model"])):
if last AIMessage has no tool_calls
AND todos exist with status != "completed"
AND completion_reminder_count < 2:
inject HumanMessage(name="todo_completion_reminder")
return {"jump_to": "model", "messages": [reminder]}
The _MAX_COMPLETION_REMINDERS = 2 cap prevents an infinite retry loop if the model is genuinely stuck.
Hook execution order in DeerFlow
before_model
└─ TodoMiddleware.before_model ← context-loss check / inject reminder
model
after_model
└─ TodoMiddleware.after_model
└─ super().after_model() ← parallel write_todos guard (base class)
└─ DeerFlow premature-exit check
Reminder message anatomy
| Name | Direction | Trigger |
|---|---|---|
todo_reminder |
HumanMessage |
before_model: write_todos not visible in window |
todo_completion_reminder |
HumanMessage |
after_model: final response but incomplete todos |
Both use <system_reminder> XML tags in the content so the model can distinguish injected control messages from user content.
Related
- LangChain LangGraph Agent Loop (Modern) — middleware lifecycle this hooks into;
jump_tostate field - Source - DeerFlow TodoMiddleware & LangChain TodoListMiddleware
- Compaction — context truncation that triggers the context-loss scenario