Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5c28fdd
refactor(hotel_receptionist): derive book_room's next action from a s…
u9g Aug 13, 2026
4c33cc0
fix(hotel_receptionist): refuse book_room's out-of-turn calls with wh…
u9g Aug 13, 2026
731d984
fix(hotel_receptionist): give the caller the cheapest matching room
u9g Aug 13, 2026
429684f
fix(hotel_receptionist): quote the price per room type AND view
u9g Aug 13, 2026
7f8fd81
fix(hotel_receptionist): settle the extras before any total exists
u9g Aug 13, 2026
e69f889
fix(hotel_receptionist): take one boolean per extra instead of a list
u9g Aug 13, 2026
00536e4
fix(hotel_receptionist): drop the instruction describing the refusals
u9g Aug 13, 2026
1267f4d
fix(hotel_receptionist): drop the four instructions the tools already…
u9g Aug 13, 2026
5a408c6
fix(hotel_receptionist): discharge owed speech from the history, not …
u9g Aug 14, 2026
c44d164
fix(hotel_receptionist): merge double_queen into queen_2beds
u9g Aug 13, 2026
75a7a11
fix(hotel_receptionist): pin HOTEL_TODAY when running a simulation
u9g Aug 14, 2026
4583ba5
fix(hotel_receptionist): pick a pairing that is still impossible
u9g Aug 14, 2026
9537c82
fix(hotel_receptionist): judge the ambiguous double by outcome, not t…
u9g Aug 14, 2026
116b747
fix(hotel_receptionist): read the party size back with the booking
u9g Aug 14, 2026
738c25e
fix(workflows): use a value already in the conversation instead of re…
u9g Aug 14, 2026
8c95cdc
fix(hotel_receptionist): keep the room a date correction didn't inval…
u9g Aug 14, 2026
2a8d4c2
fix(hotel_receptionist): let every stage record a followup
u9g Aug 14, 2026
a5bbdba
test(hotel_receptionist): tag the scenarios that run the book-room flow
u9g Aug 14, 2026
6ccf633
fix(hotel_receptionist): don't re-offer a room the flow never had
u9g Aug 14, 2026
d4cf631
fix(agents): refuse a second inline AgentTask on the same activity (#…
u9g Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion examples/hotel_receptionist/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

sys.path.append(os.path.dirname(os.path.abspath(__file__)))

# scenarios.yaml dates are literals against this date. hotel_db.TODAY freezes at
# import time, so the pin has to precede every import that reaches hotel_db.
if "--simulation" in sys.argv:
os.environ.setdefault("HOTEL_TODAY", "2026-06-08")

from benchmark import build_expected, diff_databases
from common import Userdata
from dotenv import load_dotenv
Expand All @@ -19,7 +24,7 @@
from run_artifacts import dump_run_artifacts
from tools_restaurant import RestaurantToolsMixin
from tools_rooms import RoomToolsMixin
from tools_services import ServicesToolsMixin
from tools_services import ServicesToolsMixin, record_followup
from ui_view import UiView

from livekit.agents import (
Expand Down Expand Up @@ -213,6 +218,11 @@ async def hotel_receptionist_agent(ctx: JobContext) -> None:
userdata = Userdata(db=db)
session = AgentSession[Userdata](
userdata=userdata,
# Session-scoped, so every agent and every AgentTask in the call can reach
# it - including the name / email / phone dialogs. A caller can abandon
# anywhere, and the alternative to recording the callback where they say so
# is promising one that was never written.
tools=[record_followup],
# An explicit VAD is required (not the bundled default): without it the
# speaking anchor falls back to the STT stream clock, which drifts into the
# future across a long call / nested-task switch and makes the turn-commit
Expand Down
340 changes: 285 additions & 55 deletions examples/hotel_receptionist/book_room.py

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions examples/hotel_receptionist/fake_data/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@
("RM_203", "king", 24000, 2, 1, 0, "city"),
("RM_204", "queen_2beds", 22000, 4, 0, 0, "city"),
("RM_205", "queen_2beds", 22000, 4, 0, 1, "garden"),
("RM_206", "double_queen", 26000, 4, 0, 0, "ocean"),
("RM_206", "queen_2beds", 26000, 4, 0, 0, "ocean"),
("RM_301", "king", 28000, 2, 0, 0, "ocean"),
("RM_302", "king", 28000, 2, 0, 0, "ocean"),
("RM_303", "queen_2beds", 24000, 4, 0, 0, "city"),
("RM_304", "double_queen", 28000, 4, 0, 1, "ocean"),
("RM_304", "queen_2beds", 28000, 4, 0, 1, "ocean"),
("RM_401", "suite", 48000, 4, 0, 1, "ocean"),
("RM_402", "suite", 52000, 4, 0, 0, "ocean"),
("RM_PH", "penthouse", 120000, 6, 0, 1, "ocean"),
Expand Down Expand Up @@ -86,9 +86,11 @@
# a lower rate than Kenji Tanaka's king, so the walk resolver correctly
# never offers it to him and his walk scenario stays intact.
# --- Double-booked next weekend, but the house can absorb it -----------
# Tom Whelan's double queen (206) collides with Grace Lin's stay, and the
# other double queen (304) is blocked by Noah Petrov - so the only room
# that fits his family of four is the suite: the free-upgrade scenario.
# Tom Whelan's ocean queen (206) collides with Grace Lin's stay, and the
# other ocean queen (304) is blocked by Noah Petrov. The resolver only
# considers rooms at or above the rate already paid, so the city/garden
# queens stay out of reach despite fitting four and being free, and the
# cheapest room left to it is the suite: the free-upgrade scenario.
("Tom", "Whelan", "tom.whelan@gmail.com", "+1 415 555 0457", "TW55", "206", 4, 3, 4, [], "5126", "confirmed"),
("Grace", "Lin", "grace.lin@gmail.com", "+1 415 555 0463", "GL09", "206", 3, 3, 3, [], "8854", "confirmed"),
("Noah", "Petrov", "noah.petrov@gmail.com", "+1 415 555 0478", "NP66", "304", 3, 4, 4, [], "1937", "confirmed"),
Expand Down
62 changes: 47 additions & 15 deletions examples/hotel_receptionist/hotel_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ def speak_usd(cents: int) -> str:
return f"{dollars} dollars and {change} cents"


def describe_extras(nights: int) -> str:
"""Each extra and what it adds to a stay of this length.

Priced through extras_total so there is no second pricing table to drift, and
priced for the actual nights, since breakfast and valet are per-night while
late checkout and the pet fee are one-off.
"""
return "\n".join(
f"- {extra.replace('_', ' ')}: adds {speak_usd(extras_total([extra], nights))}"
for extra in sorted(ALLOWED_EXTRAS)
)


def speak_time(t: time) -> str:
"""A clock time as natural speech, e.g. '7 PM', '6:30 PM'."""
hour = t.hour % 12 or 12
Expand Down Expand Up @@ -172,16 +185,38 @@ class DisputePolicy:
)
MAX_PARTY_SIZE = 6

RoomType = Literal["king", "queen_2beds", "double_queen", "suite", "penthouse"]
RoomType = Literal["king", "queen_2beds", "suite", "penthouse"]


@dataclass
class RoomTypeAvailability:
class RoomOption:
"""One bookable type+view pairing and what the cheapest room in it costs.

The pairing is the unit a caller actually picks, not the type: rate varies with
the view (a city king is 240/night, an ocean king 260), so a price quoted per
type is a price that moves once the view is known. Splitting the row also means
a view a type doesn't have has no row to be offered from.
"""

type: RoomType
view: str
# cheapest free room in this pairing; rate varies room to room within it, and
# _SQL_FREE_ROOM hands out the cheapest, so this is the rate that gets charged
nightly_rate: int
# every distinct view available for this type on the dates,
# e.g. ["city", "garden"]
views: list[str]


def describe_room_options(options: Sequence[RoomOption]) -> str:
"""The bookable pairings as one line each, cheapest first.

One line is one type with one view and that pairing's price. Rolling a type's
views onto a single line lets a neighbouring line's view bind to the wrong type
- a garden-view king, which has never existed - and hides that the view
moves the price, so a figure spoken before the view is settled has to change.
"""
return "\n".join(
f"- {o.type.replace('_', ' ')}, {o.view} view: {speak_usd(o.nightly_rate)}/night"
for o in options
)


@dataclass
Expand Down Expand Up @@ -534,15 +569,15 @@ def close(self) -> None:
async def aclose(self) -> None:
self.close()

async def list_room_types_available(
async def list_room_options(
self,
*,
check_in: date,
check_out: date,
guests: int,
smoking: bool | None = None,
exclude_booking_code: str | None = None,
) -> list[RoomTypeAvailability]:
) -> list[RoomOption]:
rows = self.connection.execute(
_SQL_AVAILABILITY,
{
Expand All @@ -553,10 +588,7 @@ async def list_room_types_available(
"exclude": exclude_booking_code,
},
)
return [
RoomTypeAvailability(t, rate, views=sorted((concat or "").split(",")))
for t, rate, concat in rows
]
return [RoomOption(t, view, rate) for t, view, rate in rows]

async def list_restaurant_availability(
self, *, on_date: date, party_size: int
Expand Down Expand Up @@ -1643,7 +1675,7 @@ def _install_schema(conn: apsw.Connection) -> None:

CREATE TABLE IF NOT EXISTS hotel_rooms (
id TEXT PRIMARY KEY, -- human room number, e.g. 'RM_201' (floor 2, room 01)
type TEXT NOT NULL CHECK (type IN ('king','queen_2beds','double_queen','suite','penthouse')),
type TEXT NOT NULL CHECK (type IN ('king','queen_2beds','suite','penthouse')),
nightly_rate INTEGER NOT NULL,
max_occupancy INTEGER NOT NULL,
smoking BOOLEAN NOT NULL DEFAULT 0,
Expand Down Expand Up @@ -1958,11 +1990,11 @@ def _install_schema(conn: apsw.Connection) -> None:
WHERE b.room_id = hotel_rooms.id AND b.status = 'confirmed'
AND (:exclude IS NULL OR b.code != :exclude)
AND NOT (b.check_out <= :check_in OR b.check_in >= :check_out))
ORDER BY CASE WHEN id = :prefer THEN 0 ELSE 1 END, id LIMIT 1
ORDER BY CASE WHEN id = :prefer THEN 0 ELSE 1 END, nightly_rate, id LIMIT 1
"""

_SQL_AVAILABILITY = """
SELECT r.type, r.nightly_rate, GROUP_CONCAT(DISTINCT r.room_view)
SELECT r.type, r.room_view, MIN(r.nightly_rate)
FROM hotel_rooms r
WHERE r.max_occupancy >= :guests
AND (:smoking IS NULL OR r.smoking = :smoking)
Expand All @@ -1971,7 +2003,7 @@ def _install_schema(conn: apsw.Connection) -> None:
WHERE b.room_id = r.id AND b.status = 'confirmed'
AND (:exclude IS NULL OR b.code != :exclude)
AND NOT (b.check_out <= :check_in OR b.check_in >= :check_out))
GROUP BY r.type ORDER BY r.nightly_rate
GROUP BY r.type, r.room_view ORDER BY MIN(r.nightly_rate), r.type, r.room_view
"""

_SQL_FREE_TABLE = """
Expand Down
23 changes: 12 additions & 11 deletions examples/hotel_receptionist/modify_booking.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
RoomExtra,
RoomType,
Unavailable,
describe_room_options,
speak_usd,
)
from persona import COMMON_INSTRUCTIONS
Expand Down Expand Up @@ -136,7 +137,7 @@ async def set_stay(
if check_in < TODAY and check_in != self._existing.check_in:
raise ToolError("check-in can't be in the past")

avail = await self._db.list_room_types_available(
avail = await self._db.list_room_options(
check_in=check_in,
check_out=check_out,
guests=guests,
Expand Down Expand Up @@ -181,31 +182,31 @@ async def choose_room(
A stated view moves the guest to a room with that view (this is how you resolve "I booked a garden view but my room has none"). The view is a property of specific rooms, NOT a separate type - if the requested view isn't available for the chosen type, this errors with where that view IS available, so you can offer the right type. Omit view entirely unless the caller asks for one.

Args:
room_type: Room type for the booking (king / queen_2beds / double_queen / suite / penthouse).
room_type: Room type for the booking (king / queen_2beds / suite / penthouse).
extras: Full new list of extras after the caller's change.
smoking_room: True if the caller wants a smoking-permitted room.
view: The view the caller asked for (city / garden / ocean), ONLY if they stated one - omit entirely otherwise.
"""
avail = await self._db.list_room_types_available(
avail = await self._db.list_room_options(
check_in=self._check_in,
check_out=self._check_out,
guests=self._guests,
smoking=smoking_room,
exclude_booking_code=self._existing.code,
)
chosen = next((a for a in avail if a.type == room_type), None)
if chosen is None:
for_type = [a for a in avail if a.type == room_type]
if not for_type:
kind = "smoking " if smoking_room else ""
offer = ", ".join(sorted(a.type for a in avail)) or "nothing for those dates"
offer = ", ".join(sorted({a.type for a in avail})) or "nothing for those dates"
raise ToolError(f"no {kind}{room_type} available; offer one of: {offer}")
# Models sometimes send placeholder strings for optional args they
# should omit - normalize those to "no view preference".
if view is not None:
view = view.strip().casefold()
if view in ("", "null", "none", "any", "no preference", "unspecified"):
view = None
if view is not None and view not in chosen.views:
matching = [a.type for a in avail if view in a.views]
if view is not None and view not in {a.view for a in for_type}:
matching = sorted({a.type for a in avail if a.view == view})
if matching:
rec = " or ".join(t.replace("_", " ") for t in matching)
raise ToolError(
Expand All @@ -215,10 +216,10 @@ async def choose_room(
f"as a downgrade), then call choose_room again with that type and view to "
f"complete the move. Do NOT give up to a manager callback - this flow can do it."
)
where = ", ".join(f"{a.type.replace('_', ' ')} ({' or '.join(a.views)})" for a in avail)
raise ToolError(
f"no {view}-view room of any type for those dates - the views by room type are: "
f"{where}. Be honest that the exact view isn't open, and offer the closest option."
f"no {view}-view room of any type for those dates - the pairings open are:\n"
f"{describe_room_options(avail)}\n"
"Be honest that the exact view isn't open, and offer the closest option."
)
self._room_type = room_type
self._view = view
Expand Down
4 changes: 2 additions & 2 deletions examples/hotel_receptionist/persona.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@

# How you handle options
When a tool returns multiple choices, release information progressively, one dimension at a time. First turn: name only the categories along the most natural narrowing dimension (the kinds, not their prices, views, or counts). Save the details for after the caller filters.
- Bad: "We have a queen for two-twenty, a king for two-forty, and a double queen for two-sixty. Any preference?"
- Good: "Sure - queen, king, or double queen?"
- Bad: "We have a queen for two-twenty, a king for two-forty, and a suite for four-eighty. Any preference?"
- Good: "Sure - queen, king, or suite?"
- After they pick king: "Got it. Two-forty a night, ocean view."

The same rule applies to text returns from info tools. If the caller asks "what's on the menu?", name the categories and offer to narrow ("starters, mains, desserts - anything in particular?"), don't recite every dish. If they ask about a specific dish or detail you don't have, offer to take their question for the kitchen via record_followup - never tell the caller to look it up themselves online or elsewhere; they called us, that's our job.
Expand Down
Loading
Loading