"Add eggs and milk to that list."
A perfectly reasonable follow-up message. You just created a grocery list two messages ago. The agent knows what "that list" means. Except when it doesn't — and worse, when it pretends it does.
PR #307 shipped a three-commit fix for silent failures in follow-up list operations. The commits tell a debugging story: each one caught a different layer where the system was lying to itself.
Layer 1: The Multi-Intent Decomposer Had Amnesia
The multi-intent decomposer breaks complex requests into parallel tool calls. "Add eggs to my grocery list and mark buy milk as done" becomes two separate operations. Fast, efficient, elegant.
But it was doing this decomposition with no conversation history. When you said "that list" in a follow-up, the decomposer had no idea which list you meant. Live replay from SMS and Gmail channels showed the smoking gun: the system was literally passing the string "that list" to tool handlers, expecting them to resolve it.
The fix: give the decomposer a 20-turn history window with messages truncated to 600 characters. Wide enough to catch "I'm making a grocery list for Saturday" from 8 messages back, cheap enough to run on every turn. The synthesis prompt kept its 10-turn window — it doesn't need to resolve references, just format results.
Layer 2: Success Wasn't Success
Even with history, stateless channels (SMS without thread context, Gmail with no prior messages) still give the decomposer nothing to work with. A user texts "add eggs" as their first message. Which list?
The tool handler would fail to resolve the list and return a helpful message: "I couldn't find a list to add items to. Your lists: Groceries, Work, Ideas." Perfect user experience. Terrible system experience: the handler returned status="success".
When a multi-intent turn includes a failed add, the executor would synthesize a reply saying everything worked — because technically, the tool ran without throwing an exception. The fact that it wrote nothing to the database was invisible at the orchestration layer.
The fix: introduce a FailedReply type. It's still a string subclass (so all the existing message formatting and test assertions keep working), but handlers now mark it status="error". UnresolvedListReply became a subclass of FailedReply, and four more handlers joined the pattern: update_list_items when no items match the filter, manage_list when you forget the new name or theme, promote_to_reminder when the item is ambiguous, and organize_lists when the theme mapping is empty.
Now when every tool intent in a multi-intent turn fails, the system recognizes it and falls back to the full agent flow. Let the agent handle disambiguation with its complete context instead of synthesizing a lie from failures.
Layer 3: Every Other "Nothing Happened" Case
Independent review of the second commit found more handlers that still returned status="success" for operations that wrote nothing: update_list_items (no items matched), promote_to_reminder (item not found or ambiguous), manage_list (missing rename/theme value or unknown action), and organize_lists (missing theme mapping).
The FailedReply pattern generalized cleanly. Each case that was a valid user message but did nothing is now wrapped in FailedReply(), and the handler machinery automatically marks it status="error". One exception stayed success: query_lists with no results. An empty answer is still an answer — the user asked a question and got a truthful response.
The Testing Archaeology
Three commits, 679 lines changed, 10 files touched. The test suite tells the forensic story: test_list_followup_issues.py documents the three original regressions (priority enum mismatch, error-prefixed results reported as OK, missing VoI tool mappings). test_list_followup_resilience.py pins 338 lines of behaviour for unresolved lists, all-tool-failure fallback, and decomposer-vs-synthesis history windows. Two assertions in test_list_e2e_chat.py encoded the old success status and had to flip to "error".
The commit message archaeology is even better. Each commit builds on the last: "found during review", "independent review found", "follow-up to the history fix". This wasn't a single bug. It was a systemic issue with how the multi-intent executor understood success.
What's Next
The FailedReply pattern is now established infrastructure. Any future tool that can fail in a "you asked for something valid, but I couldn't do it" way should use it. The type system enforces it: isinstance(reply, FailedReply) at the handler boundary.
The 20-turn decomposer history window is a cost/capability tradeoff we can now measure. If resolution quality improves, we'll keep it. If the token cost is prohibitive, we have a clear rollback path (and the tests to prove it).
And the all-tool-failure fallback opens a future optimization: if the decomposer produces only tool failures, maybe don't run them at all. Detect the hopeless case earlier and hand it straight to the full agent. That's speculative. Right now, we're just happy "add that to my list" works.