fix(hotel_receptionist): hotel_db stored-state and read-back fixes - #6805
fix(hotel_receptionist): hotel_db stored-state and read-back fixes#6805u9g wants to merge 9 commits into
Conversation
β¦nt simulate scenarios.yaml hardcodes every date literal against HOTEL_TODAY=2026-06-08, but nothing set that env var, so plain 'lk agent simulate' ran the agent on the real clock and all 14 expected-state scenarios failed on date diffs. Resolve TODAY as: HOTEL_TODAY override > SIM_TODAY when --simulation is in argv (lk always passes it; job subprocesses inherit argv) > date.today().
β¦n form shortuuid() returns a lowercase hex suffix, but _speak_code spells every reference out in uppercase, so the caller only ever hears the uppercase form and the model passes that form back. A by-code lookup against the lowercase stored value never matched. Route every generated code through _new_code(), which uppercases at the single point of creation - the seeded codes in scenarios.yaml were already uppercase.
An LLM-supplied time can arrive tz-aware - "17:40:00Z" parses with tzinfo - and isoformat() then wrote "17:40:00+00:00" into columns that otherwise hold naive wall clock, so the row no longer compared equal to a scenario's expected state and time.fromisoformat round-trips diverged. Route every stored time through wall_clock_iso(), which drops the offset at the write.
A booking read-back listed the room type but not its view, so a guest who had just been moved to a garden-view room heard their booking described exactly as it was before the move. Add HotelDB.room_view() and put the view in the facts the modify flow reads back. _require_room splits into _normalize_room (spoken number -> id) and _room_exists so a caller that wants one without the other isn't forced through the raising path.
Availability was rendered as one pipe-delimited line, so the type -> view pairs smeared together in context: the model bound a neighboring type's view to whatever type the caller picked and offered a garden-view double queen, which has never existed. describe_room_options() puts one type per row and states whether the view is a choice to ask about or a fact to assert, so a single-view type stops being offered as a false choice. Both renderers - the booking flow and read-only browsing - share it, since a smeared pair offered while browsing survives into the booking flow.
β¦ PH" The penthouse's room id is RM_PH, so every service tool that echoed the room back - wake-up calls, emergency dispatch, Do-Not-Disturb, and the not-found errors - read it out as "room PH", a room number no guest has ever been given. speak_room() renders a room from either its id or the spoken number, and names the penthouse as the suite it is.
β¦he pickup margin A concierge reconfirming a flight naturally captures when it departs; without it the airport-car pickup margin could only ever be a prose instruction the model routinely skipped. flight_reconfirmations gains a nullable departure_time, request_flight_reconfirmation captures it, and book_airport_car looks it up for the pickup's date and hands the agent the computed margin to say back - including TIGHT and not-before-departure warnings. The flight scenario's expected state now includes 17:40:00.
β¦endments florist_orders.deliver_to was one free-text column holding whichever idea of the destination the model formed - a bare room number, the prose "Penthouse Suite" for RM_PH, or an arriving guest's name - so a room could not be validated, and nothing distinguished a room from a person. Split it into room_id (FK, checked to exist) and recipient_name, with a CHECK that exactly one is set; a room wins when both arrive. Add delivery_instructions plus amend_florist_order, so a handling request made after the order lands on the order the florist reads rather than in a followup nobody routes. The florist scenarios now seed a real room (RM_304, RM_PH) instead of the unseeded 412.
Every room-taking tool took a free-text room, so the model could pass a phrase that names no room at all - "the front desk", a stand-in word, the penthouse spelled a dozen ways - and the string reached the database before anything rejected it. Room is a discriminated union of NumberedRoom and PenthouseSuite, which makes a fabricated destination inexpressible at the schema boundary and gives the penthouse one canonical spelling. room_to_id() converts to the stored id at the tool edge, so the tools now hand the db a canonical id and speak_room() no longer has to normalize what it renders.
| try: | ||
| await ctx.userdata.db.amend_florist_order( | ||
| code=order_code, delivery_instructions=delivery_instruction | ||
| ) |
There was a problem hiding this comment.
π‘ A follow-up delivery note for a flower order can be rejected as an unknown order
The reference the caller reads back is looked up without being cleaned up first (amend_florist_order at examples/hotel_receptionist/tools_services.py:425-427), so a reference heard with spaces or in lower case is treated as unknown and the note never reaches the order.
Impact: The caller is told their flower order doesn't exist and the delivery instruction is silently lost.
Case/whitespace normalization missing versus every other code-taking tool
Generated codes are now stored uppercase (_new_code at examples/hotel_receptionist/hotel_db.py:85-89), and HotelDB.amend_florist_order (examples/hotel_receptionist/hotel_db.py:1325-1331) matches code exactly with no UPPER() on either side. Every other tool that accepts a spoken-back reference normalizes it first β verify_booking.py:63 and tools_restaurant.py:78,104,140 all do confirmation_code.replace(" ", "").upper(), and find_booking/find_restaurant_reservation additionally .upper() the parameter (hotel_db.py:639,654). The new tool does neither, so a transcription like "flr-ab12" or "FLR AB12" yields changed == 0 β NotFound β ToolError.
| try: | |
| await ctx.userdata.db.amend_florist_order( | |
| code=order_code, delivery_instructions=delivery_instruction | |
| ) | |
| try: | |
| await ctx.userdata.db.amend_florist_order( | |
| code=order_code.replace(" ", "").upper(), delivery_instructions=delivery_instruction | |
| ) |
Was this helpful? React with π or π to provide feedback.
| return "\n".join( | ||
| f"- {a.type.replace('_', ' ')}: {speak_usd(a.nightly_rate)}/night, " | ||
| + ( | ||
| f"{' or '.join(a.views)} view - ask which of those two they want" | ||
| if len(a.views) > 1 | ||
| else f"{a.views[0]} view only - say so as a fact, there is no view to ask about" | ||
| ) | ||
| for a in avail | ||
| ) |
There was a problem hiding this comment.
π‘ Callers can be told a room type only comes with one view when other views are merely booked up
The list of views that happen to be free for the requested dates is described to the agent as the complete set of views a room type has (describe_room_options at examples/hotel_receptionist/hotel_db.py:224-232), so the agent states an availability accident as a permanent fact about the hotel.
Impact: Guests are told, as fact, that a room type has no ocean/garden view when such rooms exist and are simply taken for those dates.
Views come from the availability query, not from inventory
_SQL_AVAILABILITY (examples/hotel_receptionist/hotel_db.py:2087-2098) groups by type over only the rooms that are unbooked for the requested range and match the smoking filter, so RoomTypeAvailability.views is an availability-derived set. The new renderer prefixes it with "the views on a type's line are the only views that type has" (book_room.py:145, tools_rooms.py:174) and, in the single-view branch, instructs the model: "{view} view only - say so as a fact, there is no view to ask about". With the seed data a king is city+ocean; if the ocean kings are booked for those dates the agent will assert kings only come with a city view. Rewording to availability terms ("the only rooms free for these dates are -view") keeps the anti-smearing benefit without asserting a false inventory fact.
Prompt for agents
describe_room_options in examples/hotel_receptionist/hotel_db.py renders availability results with wording that asserts the listed views are the only views a room type has ("the views on a type's line are the only views that type has" in book_room.py set_stay and tools_rooms.check_room_availability, and "<view> view only - say so as a fact, there is no view to ask about"). The views come from _SQL_AVAILABILITY, which only includes rooms free for the requested dates and matching the smoking filter, so a view that exists in inventory but is booked out disappears from the line. The agent will then state a false fact to the caller. Reword the per-line verdict and the header so they talk about what is available for those dates rather than what the type has, while keeping the one-row-per-type binding that this change was made for.
Was this helpful? React with π or π to provide feedback.
Summary
The
examples/hotel_receptionist/hotel_db.pychanges from #6567, brought in as independent semantic commits so each one stands or falls on its own. Oldest first:lk agent simulateβscenarios.yamlhardcodes every date literal againstHOTEL_TODAY=2026-06-08, but nothing set that env var, so a plainlk agent simulateran the agent on the real clock and all 14 expected-state scenarios failed on date diffs.shortuuid()returns a lowercase suffix while_speak_codespells every reference out in uppercase, so a by-code lookup against the stored value never matched the form the caller heard and passed back.isoformat()then wrote an offset into columns that otherwise hold naive wall clock, breaking both expected-state comparison andtime.fromisoformatround-trips._require_roomsplits into_normalize_roomand_room_exists.RM_PH, so every service tool that echoed the room back read out a room number no guest has ever been given.deliver_towas one free-text column holding whichever idea of the destination the model formed, so a room could not be validated and nothing distinguished a room from a person. It splits intoroom_id(FK, checked to exist) andrecipient_namewith a CHECK that exactly one is set, plusdelivery_instructionsandamend_florist_orderso a handling request made after the order lands on the order the florist reads.Roomunion replaces the room string β every room-taking tool took free text, so the model could pass a phrase naming no room at all and the string reached the database before anything rejected it.Roomis a discriminated union ofNumberedRoomandPenthouseSuite, which makes a fabricated destination inexpressible at the schema boundary and gives the penthouse one canonical spelling.room_to_id()converts at the tool edge, so the tools hand the db a canonical id andspeak_room()no longer normalizes what it renders.Two things beyond
hotel_db.pyare mine rather than #6567's, both forced by the changes above:benchmark.pydeniesdelivery_instructionsfrom expected-state comparison. It is agent-written free text, and_select_sqlcompares every non-denied column, so a captured handling note would fail the diff against the seeded empty default.seed.py. Free-textdeliver_totolerated an unseeded room; the new FK and existence check do not.order_flowersuses_room_exists()from commit 4 where #6567 inlines the same query β that helper did not exist yet at that point in its history. Semantically identical, so expect a one-hunk conflict there and nowhere else inhotel_db.py.Testing
pytest --unitβ 1942 passed, 5 skippedruff format --checkandruff checkclean at every commit individually, and each commit imports on its own"the front desk",{"type": "suite"}, and a numberless room are all rejected at parseRM_304andRM_PHstore and read back for DND, wake-up calls, dispatch, and florist orders, and an unseeded room is refusedmypy examples/hotel_receptionist/reports one error, a module-path mapping issue infake_data/seed.py, which reproduces unchanged onmain. The repo'stype-checkgate coverslivekit.agentsand the plugins, notexamples/.