ADR-0016: Agentic manage-layer for existing-appointment flows
Date: 2026-06-14 Status: Accepted Deciders: Adrian (founder), Claude Opus 4.8 (design + implementation agent) Extends: ADR-0005 (orchestration model — adds an agentic sub-orchestrator alongside the FSM; does not supersede it)
Context
ADR-0005 chose an FSM-first orchestration model: a deterministic LangGraph finite-state machine drives every conversation, and the LLM is used only for narrow extraction (intent classification, slot/service/staff parsing). Each conversational capability — collect-service, collect-slot, cancel, reschedule, view-bookings — is a bespoke FSM node/subgraph with its own keyword/ordinal parsing.
Over a single day of pilot QA this model's cost compounded into three separate "the same bug, different flow" defects:
- PR#29 —
COLLECT_SERVICEself-looped on a no-match (recursion cap → spurious handoff). - PR#45 — the "Other dates" slot pager cleared state and deferred the re-fetch, rendering a premature "no more openings".
- The cancel loop (the trigger for this ADR) — a customer with three appointments typed "cancel all", then "1", "2", "3", "all of them" — and the bot replied "Which appointment would you like to cancel?" to every message, forever. Root cause: the dispatcher hard-maps
state=CANCEL_REQUEST → intent="cancel"each turn (never re-classifies), re-runs the cancel subgraph fresh, and the cancel LLM prompt has no ordinal examples and no persisted indexed candidate list, so a bare "1"/"all of them" can't be resolved to an appointment.
The pattern: reference-resolution over a list ("which of these did you mean?")
is exactly what an LLM is good at, and exactly what the hand-built FSM branches keep
getting wrong — each uncovered phrasing cracks independently, and we patch them one
at a time. The booking/slot flow only works because it was hand-built with
slot_options persistence + _parse_slot_ordinal (and even that was fixed twice).
The substrate to do better already exists but was never activated: ADR-0005 specs a
directory-per-tool MCP-shape tool registry with safety_class-driven confirmation
gating — but no agent loop was ever built over it. The deterministic write paths
also exist (app/services/appointment_actions.py cancel/reschedule + the
buffer-padded conflict UPDATE; find_available_slots; _load_upcoming_appointments).
The booking + payment funnel, by contrast, genuinely benefits from FSM determinism: slot reservation, payment idempotency (ADR-0007), fresh-thread-per-booking (ADR-0003), per-booking cost ceilings (ADR-0005), and pooled provisional-assignment (ADR-0015).
Decision
Add an agentic manage-layer: an LLM tool-calling agent that owns the "my existing appointments" cluster (view/list, cancel, reschedule, and questions about existing bookings). The booking + payment + pooled-allocation funnel stays on the FSM, unchanged.
-
Stateless-per-turn tool-calling agent (
app/agents/manage/). Each manage turn the agent receives the conversation history (from the LangGraph checkpoint) + the customer's freshly-fetched real appointments + the tool schemas, reasons, and calls tools in a bounded loop (max 5 iterations/turn). It resolves "1", "the first one", "all of them", "my Tuesday massage" against the appointment list in context — there is no bespoke "awaiting selection" FSM state. -
Real-data tools in the ADR-0005 registry:
list_appointments()(read),propose_reschedule_slots(id)(read, reusesfind_available_slots),cancel_appointment(ids)(confirm),reschedule_appointment(id, new_start_at)(confirm). The LLM chooses the tool + the real ids the tools returned — it never invents appointments. Mutation tools resolve to the existing deterministic SQL write paths. -
Confirm gating via
safety_class=confirm(the ADR-0005 mechanism, now used): a mutation tool call is NOT executed; the framework renders the agent's NL proposal and holds the concrete tool call with resolved ids in the checkpoint; a YES releases it (the SQL runs), anything else discards it. The exact ids are pinned at proposal time, not re-derived from "yes". "cancel all" → one batch confirm. -
Dispatch routing: manage intents (cancel/reschedule/view + "about my bookings") route to the agent instead of the FSM subgraphs (
_dispatch_cancel/_dispatch_reschedule/_dispatch_view_bookings,cancel_graph.py,reschedule_graph.py, and their prompts are removed once the agent covers them). Mid-manage "book a new one" hands back to the booking-FSM entry. -
Determinism guardrails keep the agentic layer safe: real-data tools (no hallucinated appointments), confirm-before-mutate, a bounded loop, the ADR-0005 cost ceiling wrapping the loop, and unchanged deterministic SQL writes.
-
Pooled-allocation fixes:
list_appointmentshides the therapist name forprovisionalrows ("therapist to be confirmed") — consistent with ADR-0015's pooled booking confirmation (corrects the live name-leak); reschedule re-pools a provisional appointment; cancel works on provisional/finalized alike.
Phased: (1) agent core + tools + confirm gating + dispatch routing (cancel + view working — the live bug fixed); (2) reschedule + the pooled name-fix + removal of the dead FSM subgraphs.
Decision context
- Latency: a manage turn now runs a tool-calling loop (1–3 LLM round-trips for a resolve-then-act, vs the old single narrow-extraction call) — est. +0.5–1.5s p50 on manage turns only (cancel/reschedule/view), not on the booking funnel. Bounded at 5 iterations. Acceptable: manage turns are low-frequency vs booking, and the old path was broken (infinite loop), so any working latency is a strict improvement.
- Dependency surface: zero new packages — reuses the existing LLMRouter, the
ADR-0005 tool registry, the existing SQL write paths. New code is the agent loop +
4 tool wrappers + the manage prompt; net code likely DECREASES (removes
cancel_graph.py+reschedule_graph.py+ 2 prompts + 3 dispatcher branches). - Debuggability: each tool call + result is logged (structured); a failure is "the agent called cancel_appointment([id]) and the SQL conflict guard rejected it", which is far more legible than "the FSM re-prompted for the 5th time". The held confirm tool-call is inspectable in the checkpoint. The risk shifts from "uncovered branch" to "the LLM picked the wrong tool/id" — mitigated by real-data tools + confirm-before-mutate.
- Reversibility: moderate. The manage agent is additive; the old FSM subgraphs can be kept dormant behind a flag during rollout and deleted only after the agent is proven. Reverting = route manage intents back to the FSM subgraphs (a dispatch change) if they're not yet deleted. Est. ~half a day while both coexist; higher once the subgraphs are removed (Phase 2).
- Blast radius: the dispatcher's manage routing + the removal of two subgraphs + their prompts. The booking funnel, payment, pooled allocation, and the FSM checkpoint shape are untouched. Additive at the registry/agent layer; substitutive at the manage-dispatch branch.
- Alternative considered: keep the FSM and add
slot_options-style persisted candidates + an ordinal/"all" parser to the cancel/reschedule flows — rejected (see Alternatives): it's another hand-built patch of the exact pattern that has cracked three times, and doesn't generalize to the next phrasing.
Consequences
Positive
- The whole class of manage-flow brittleness is fixed at once: "cancel all", "1", "the first one", "all of them", "my Tuesday massage" all resolve natively.
- New manage phrasings need no new code — the agent reasons over the appointment list.
- Net code likely shrinks (two subgraphs + two prompts + three dispatcher branches removed).
- The ADR-0005 tool registry +
safety_classgating finally earns its design. - The pooled name-leak in the list view is fixed in passing.
Negative
- A manage turn is now non-deterministic in which tool/path the LLM picks (mitigated by real-data tools + confirm-before-mutate + bounded loop + cost ceiling).
- Higher per-manage-turn LLM cost + latency than a single extraction call.
- New failure mode: the LLM picks the wrong real id (e.g. cancels the wrong listed appointment) — caught by the NL confirm before any write, but a customer who rubber-stamps "yes" could still confirm a mis-resolved action. The proposal copy must name the concrete appointment(s).
- Two orchestration paradigms now coexist (FSM for booking, agent for manage) — more conceptual surface than a single model.
Neutral
- The booking FSM, payment, and pooled allocation are unchanged.
- The agent runs inside the existing thread/dispatch + checkpoint; no new persistence beyond the held confirm tool-call (which lives in the checkpoint).
Alternatives Considered
Alternative 1: Keep the FSM; add persisted candidates + an ordinal/"all" parser to cancel/reschedule
- Mirror the slot flow: store the candidate appointment list on state, add a
_parse_cancel_ordinal+ an "all" handler, persist an awaiting-selection sub-state. - Why rejected: it is another hand-built patch of the exact pattern that has cracked three times in one day (PR#29, PR#45, the cancel loop). It fixes "1"/"all" but not the next uncorked phrasing ("the massage one", "the one with Grace", "everything after Tuesday"), and it must be repeated per-flow. It treats the symptom, not the design cost ADR-0005 incurred.
Alternative 2: Full agentic rewrite (agent owns booking + payment too)
- One agent with booking/payment/manage tools; retire the FSM entirely.
- Why rejected: the booking + payment + pooled funnel needs determinism the FSM provides — slot reservation, payment idempotency (ADR-0007), fresh-thread-per-booking (ADR-0003), cost ceilings, pooled provisional-assignment (ADR-0015). Putting an LLM in the payment-critical path risks double-charges / mis-reservations for no benefit (booking is a linear funnel the FSM handles fine). High blast radius, low upside.
Alternative 3: Do nothing (status quo)
- Leave the cancel loop in place.
- Why rejected: cancellation is fully broken for any customer with >1 appointment — an unacceptable pilot defect, and the brittleness recurs across the manage flows.