Chatbot HITL Reject Deterministic Cancel
On May 26, 2026, the chatbot correctly skipped a rejected write tool but still told the user the work had been completed. The fix was to make rejection a graph-owned outcome instead of an LLM-narrated one.
Failure
The broken flow was:
- the model requested a write tool such as
create_announcementorsend_sms, interrupt()paused the graph and the UI showed the pending action,- the user clicked reject,
- the tool did not execute,
- the graph still routed back through the model, which phrased the turn as if the action had succeeded.
This was a reliability bug, not a data-loss bug. The backend execution path was already skipped correctly.
Root Cause
The old reject branch wrote a ToolMessage like "the user cancelled the action" and then always routed back to the agent node.
That left the final user-visible wording up to the LLM. With no cancellation-specific rule strong enough to guarantee the phrasing, a real model could still answer as if the tool call had completed.
Fix Kept In Code
lumie-worker/services/chatbot/src/graph/agent.py and src/graph/state.py now treat reject as deterministic graph state:
ChatbotStatecarries acancelledflag,tools_nodesets that flag whendecision.get("confirmed")is false,route_after_toolssends the run to a dedicatedcancelnode before any retry or re-entry to the model,cancelled_nodeemits the fixed assistant message,- the matching
ToolMessageis still preserved so the checkpointed tool-call history stays protocol-valid.
Representative shape from lumie-worker/services/chatbot/src/graph/agent.py:
if state.get("cancelled"):
return "cancel"
if state.get("sql_retry_count", 0) >= cfg.sql_max_retries:
return "give_up"
return "agent"
Regression Guard
lumie-worker/services/chatbot/tests/test_graph.py now protects the behavior structurally instead of scripting the desired narration:
- the fake model only provides the initial tool-call response,
- the reject path asserts that
execute_toolwas never called, - the final assistant message must equal
_CANCELLED, model._index == 1proves the model was not called again after rejection.
That last assertion is the important change. It catches a wrong route even if a scripted model could otherwise hide it.
Lessons
- Explicit user rejection should be represented by graph logic, not prompt wording.
- A
ToolMessagecan be necessary for protocol correctness even when the model must not see the branch outcome on that turn. - Scripted-model tests need call-count assertions when route shape matters as much as final text.
The broader cutover context for this fix is covered in LangGraph Chatbot Production Cutover.