Skip to main content

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:

  1. the model requested a write tool such as create_announcement or send_sms,
  2. interrupt() paused the graph and the UI showed the pending action,
  3. the user clicked reject,
  4. the tool did not execute,
  5. 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:

  • ChatbotState carries a cancelled flag,
  • tools_node sets that flag when decision.get("confirmed") is false,
  • route_after_tools sends the run to a dedicated cancel node before any retry or re-entry to the model,
  • cancelled_node emits the fixed assistant message,
  • the matching ToolMessage is 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_tool was never called,
  • the final assistant message must equal _CANCELLED,
  • model._index == 1 proves 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 ToolMessage can 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.