Problem Statement — the scheduler demo doesn't reliably book a slot
Date: 2026-07-02 For: working session with Fable 5 Goal of the demo we want: a caller (or operator) can, over the phone / in the voice UI, do the three things that define "the scheduler works":
- See what's blocked/off on a given day.
- See the available free slots on a day / soonest.
- Book an hour into one of those slots — and have it actually land on the calendar, not escalate.
Today the demo does not reliably do #3, and there is no clean path for #1/#2. This document states the problem precisely so we can fix it together.
1. What actually happens right now (evidence)
Driving flows/tutorial_book_appointment (or serene_mobile_massage /
skyline_apartments — all use the book_appointment_v1 module) in the voice UI,
the call escalates to a human instead of booking. The live log
(logs/live.log, 2026-07-02) shows why — the schedule_event tool errors:
hook_result name='schedule_event' state='ERROR'
error="time_range must be morning|afternoon|evening|HH:MM or a recognizable
phrase like 'noon'/'2 PM', got '12:00 to 15:00'"
hook_result name='schedule_event' state='ERROR'
error="time_range must be ... got '14:00:00-05:00'"
The FSM then routes book_session → handoff_to_human on booking_failed.
Root cause: the LLM naturally emits time_range values the slot-mapper
doesn't accept — a range ("12:00 to 15:00") or a full ISO datetime
("14:00:00-05:00"). The mapper (slot_mapper.py::_coerce_time_range) accepts a
narrow set (morning|afternoon|evening|HH:MM|"noon"/"2 PM") and raises on
anything else, which the flow treats as a terminal booking failure → escalate.
It is intermittent: one call in the same session succeeded (state='DONE') when
the LLM happened to format the arg correctly. So the backend works; the
LLM↔tool argument contract is brittle, and its failure mode is a hard escalate
rather than a graceful re-ask.
2. Two deeper structural problems the demo exposes
(a) There are two different scheduler implementations, and they diverge.
| System | Availability/offer | Book tool | Offer selection | Used by |
|---|---|---|---|---|
scheduling_v1 |
prefetch via schedule_event(intent=check) |
schedule_event |
scheduling_offer_slots — a deterministic locked_choice (press/say 1,2,3) |
Defined in austin_plumbing but bypassed by its intake happy-path |
book_appointment_v1 |
propose_booking |
schedule_event |
offer_slots — LLM free-text collect |
The live slot-booking flows (tutorial / serene / skyline) |
The path we hardened this session (a deterministic simulator + two real bug
fixes, B-CAL-1/2 — see calendar-scheduler-sim-report.md) is scheduling_v1,
whose offers are a locked_choice and therefore robust. But the flows a user
actually reaches in the UI use book_appointment_v1, whose offer/booking args
are LLM free-text — exactly where the time_range failure happens. We hardened
one engine and demo the other.
(b) The obvious entry point (austin_plumbing) is intake-only.
Its happy path collects "possible days" and "blocked days" and ends at a
callback summary terminal — it never offers bookable time slots. A first-time
tester (correctly) reads this as "the scheduler only shows days, not slots." The
booking states exist in the flow (via the module) but are unreachable from its
prompt.
3. What the caller-facing capabilities should be (the ask)
The scheduler should expose, reliably and demonstrably:
- "What's blocked on Thursday?" → list the off/busy times for a day.
- "When are you free Thursday afternoon?" → list free slots.
- "Book me an hour at 2 PM Thursday." → write a 60-min event, return an id.
The cal-provider backend already has the primitives:
get_available_slots(...), check_availability(...), propose_booking(...),
confirm_booking(...) (riff/providers/cal_provider_adapter/adapter.py). The gap
is surfacing them robustly through a flow and not hard-failing on arg
format.
4. The problems to solve (proposed framing for Fable 5)
- P1 — Make the offer/book arg contract robust (or remove it).
Option A: normalize in the mapper — accept ranges, ISO datetimes, and phrases,
coercing to the canonical form instead of raising (and on a truly unparseable
value, re-ask the caller, don't escalate). Option B (stronger, aligns with
the [scheduler #20 "offers are quotes"] direction): make booking select a
concrete offer_id the backend already vetted, so the LLM never hand-formats
a
time_rangeat all — it just picks an offered slot. - P2 — Pick ONE canonical scheduler path and make it the demo.
Either bring
book_appointment_v1up to the robustness of the locked_choice offer model, or route the live booking flows through the hardenedscheduling_v1engine. Two engines with different arg contracts is the underlying fragility. - P3 — A capability demo + deterministic tests for the LIVE path.
A flow (or the existing one, fixed) that demonstrably does #1/#2/#3 above, plus
a deterministic simulator for
book_appointment_v1mirroring the one we built forscheduling_v1(its offer step is LLM today; if booking selects anoffer_id, that step becomes deterministic and testable).
5. What already exists to build on
- A deterministic simulator for the
scheduling_v1offer→book loop with 133 permutation tests (tests/test_calendar_scheduler_sim.py), including conflict-recovery — it caught and fixed two real bugs (B-CAL-1/2) this session. The same harness pattern extends tobook_appointment_v1. - The cal-provider backend works (bookings succeed when args are well-formed;
david.bryan.mar@gmail.comreachable + bookable). - A runnable repro + regression guard for P1 already exists:
tests/providers/test_slot_mapper.py— runpytest tests/providers/test_slot_mapper.py -k time_range. The three formats that escalate the live demo ("12:00 to 15:00","14:00:00-05:00","2-4pm") arexfail(strict)asserting the desired behavior (normalize, never raise); they flip to green the moment P1 is fixed (forcing the marker's removal), and a companion test guards that the currently-working formats (afternoon/2 PM/14:00/noon) keep working. - The gated scheduler goal #20 ("offers are quotes" — cal-provider returns
concrete bookable offers with an
offer_id+policy_hash; booking passes anoffer_idand cal-provider revalidates) is exactly the P1-Option-B fix. This demo failure is the concrete, reproducible motivation for that redesign.
6. Acceptance criteria for "fixed"
- In the voice UI, a caller can ask a day's free slots and blocked times and get a correct, spoken answer.
- A caller can book an hour into an offered slot and it lands on the calendar with an event id — no escalation on well-intentioned phrasing.
- A malformed/ambiguous time never hard-escalates; it re-asks or maps gracefully.
- The live booking path has deterministic permutation coverage (like
scheduling_v1has today), so regressions are caught before a demo.
7. One-paragraph summary
The scheduler's backend can book, but the live conversational flow
(book_appointment_v1) is brittle at the LLM↔tool boundary: the model emits
time_range formats the slot-mapper rejects (ranges, ISO datetimes), and the
failure mode is a hard escalation to a human rather than a graceful re-ask. We
also have two divergent scheduler engines — we hardened the robust,
locked_choice one (scheduling_v1) but demo the LLM-free-text one
(book_appointment_v1). The fix is to make booking select a backend-vetted
offer (the #20 "offers are quotes" direction) so the LLM never hand-formats a
time, converge on one engine, and give the live path the deterministic coverage
the other one now has.